Files
Admin ca2130258b [fix](alarm): make dismissal stick and count the devices
Four defects, three of them the same missing piece of the delivery ledger.

- listSubscriptions scanned nine destinations for ten columns and read a NULL
  subscriber_user_id into a uuid. Every alert with an anonymous subscriber
  answered 500 ("number of field descriptions must equal number of
  destinations, got 10 and 9"), and the client renders a failed read as an
  empty list — so 'Subscribed devices' said 0 while the row existed. The
  listing now uses the same scan as every other subscription read.
- alarmStatus reported the alert's commanded state, not the caller's own. An
  alert stays 'triggered' until the owner disarms or every subscriber acks, so
  a device that had already dismissed the alarm was told active:true on every
  poll and re-raised its takeover. active is now true only while the caller
  still owes the newest trigger; the owner keeps seeing their own alert.
- ackAlarm required an eventId, which the native takeover launch does not
  have. A dismissal that cannot name its event now clears the subscription's
  whole outstanding set (AckAllDeliveries / AckAllForSubscriber), so the debt
  cannot survive because the client could not echo a uuid.
- The device ledger probe (gated on DATABASE_URL, like the lifecycle probe)
  pins the per-subscriber debt, the ack-all semantics and the cascade that
  takes a device token with the destroyed alert.

Verified against the dev database and through Maestro: 2 subscriptions listed,
device A acked -> its status turns active:false while device B still gets the
409 check-in, ack-all without eventId clears the debt and drains the alert.
2026-10-19 19:34:24 +01:00

300 lines
9.4 KiB
Go

// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package actions
import (
"errors"
"net/http"
"strings"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid"
"git.dcntrlized.org/Dcntrlized/Alarm/realtime"
)
// actionResponse is the trigger/disarm result.
type actionResponse struct {
EventID string `json:"eventId"`
Action string `json:"action"`
ServerTime string `json:"serverTime"`
Delivered int `json:"delivered"`
}
type triggerRequest struct {
AlertID string `json:"alertId"`
AdminToken string `json:"adminToken"`
Message *string `json:"message"`
}
// TriggerAlert implements POST /action/triggerAlert.
//
// The state transition and the event row are written in one transaction, and
// the schema's NOTIFY trigger publishes the event on commit, so every service
// instance fans it out. This instance additionally broadcasts in-process for
// the fast path; the hub de-duplicates the two by event id.
func TriggerAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "triggerAlert"
alert, p, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
var req triggerRequest
_ = decodePayload(payload, &req)
message := alert.Message
if req.Message != nil {
m, err := validateMessage(*req.Message)
if err != nil {
validationError(w, "%v", err)
return
}
if strings.TrimSpace(m) != "" {
message = &m
}
}
updated, event, err := db.TriggerAlert(r.Context(), alert.ID, p.ID, message)
if err != nil {
dbError(w, action, err)
return
}
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindTrigger,
ServerTime: rfc3339(event.CreatedAt),
Delivered: delivered,
})
}
// DisarmAlert implements POST /action/disarmAlert.
func DisarmAlert(w http.ResponseWriter, r *http.Request, payload any) {
const action = "disarmAlert"
alert, p, ok := requireAdminToken(w, r, action, payload)
if !ok {
return
}
updated, event, err := db.DisarmAlert(r.Context(), alert.ID, p.ID, nil)
if err != nil {
dbError(w, action, err)
return
}
delivered := 0
if hub := realtime.Current(); hub != nil {
delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{
Type: "alarm",
AlertID: updated.ID.String(),
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
Message: deref(event.Message),
ServerTime: rfc3339(event.CreatedAt),
})
}
Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered)
writeJSON(w, http.StatusOK, actionResponse{
EventID: event.ID.String(),
Action: alertstate.KindDisarm,
ServerTime: rfc3339(event.CreatedAt),
Delivered: delivered,
})
}
// offlineAlarmResponse is the 409 check-in body: the standard error envelope plus
// the alarm frame the device missed while offline. 409 is a status, not a
// failure: it means "an alarm is pending and must ring now".
type offlineAlarmResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Alarm realtime.AlarmFrame `json:"alarm"`
}
// writeOfflineAlarm answers alarmStatus with 409 ERR_ALARM_TRIGGERED_OFFLINE and
// the missed trigger frame. The frame is the same shape the socket would have
// delivered (same eventId, same serverTime), so the client renders an offline
// alarm with the code path it already has for a live one.
func writeOfflineAlarm(w http.ResponseWriter, alert *db.Alert, pending *db.PendingDelivery) {
message := ""
switch {
case pending.Message != nil:
message = *pending.Message
case alert.Message != nil:
message = *alert.Message
}
var body offlineAlarmResponse
body.Error.Code = CodeAlarmTriggeredOffline
body.Error.Message = "an alarm was triggered while this device was offline; ring now"
body.Alarm = realtime.AlarmFrame{
Type: "alarm",
AlertID: alert.ID.String(),
EventID: pending.EventID.String(),
Action: alertstate.KindTrigger,
Message: message,
ServerTime: rfc3339(pending.ServerTime),
}
writeJSON(w, http.StatusConflict, body)
}
// statusView is the alarmStatus polling fallback payload (frozen field set).
type statusView struct {
Active bool `json:"active"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
}
// AlarmStatus implements GET/POST /action/alarmStatus: the cheap, indexed
// fallback a client polls when its socket is down or it was killed mid-alarm. It
// also serves as the offline check-in (409 ERR_ALARM_TRIGGERED_OFFLINE) and is
// where a missed alarm's ledger row is retired.
//
// `active` is derived from the commanded status, never stored and never
// clock-based: an alert is active from trigger until the owner disarms it, so no
// background job is needed to expire anything.
func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
const action = "alarmStatus"
c, ok := callerFor(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
validationError(w, "%v", err)
return
}
res, err := db.GetAlertWithLatestEvent(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
if c.IsDevice {
if !c.subscribedTo(alertID) {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
} else {
allowed, err := isAdminOrSubscriber(r, res.Alert, c.UserID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
}
// Offline check-in, READ-ONLY. If this device still owes an alarm, answer
// with the frame it missed instead of a quiet 200 — and clear NOTHING. The
// debt is settled only by an explicit ack (ackAlarm or the WS ack), because a
// 409 lost in transit must not forfeit the alarm: the next check-in has to
// find the same row. A trigger that has since been disarmed is not news, so
// that case falls through to the normal body (active:false).
deviceHash := ""
if !c.IsDevice {
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
deviceHash = deviceid.Hash(raw)
}
}
var pending *db.PendingDelivery
if c.IsDevice {
// The device token already identifies the subscription, so its whole
// pending set is the debt to report.
rows, err := db.PendingReplay(r.Context(), alertID, c.Sub.ID)
if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err)
return
}
if len(rows) > 0 {
pending = &rows[0]
}
} else {
var err error
pending, err = db.PendingTrigger(r.Context(), alertID, c.UserID, deviceHash)
if err != nil && !errors.Is(err, db.ErrNotFound) {
dbError(w, action, err)
return
}
}
if pending != nil && res.Event != nil && res.Event.Kind == alertstate.KindTrigger {
writeOfflineAlarm(w, res.Alert, pending)
return
}
now := time.Now().UTC()
active := alertstate.IsActive(res.Alert.Status)
if res.Event == nil || res.Event.Kind != alertstate.KindTrigger {
// A never-triggered alert, or one whose newest event is a disarm, is
// not live even if the stored status says "triggered".
active = false
}
// "Active" is a statement about THIS caller, not about the alert's commanded
// status. The alert stays triggered until the owner disarms it or every
// subscriber acknowledges, so answering a device that already dismissed the
// alarm with active:true makes a correct client re-raise the takeover on
// every poll — the alarm that will not stay dismissed. A subscriber is
// active only while it still owes the newest trigger; the owner (who has no
// delivery row) keeps seeing their own alert as live.
callerIsOwner := !c.IsDevice && res.Alert.AdminUserID == c.UserID
if active && !callerIsOwner {
var owed bool
if c.IsDevice {
owed, err = db.HasPendingDelivery(r.Context(), alertID, c.Sub.ID)
} else {
owed, err = db.HasPendingDeliveryForUser(r.Context(), alertID, c.UserID, deviceHash)
}
if err != nil {
dbError(w, action, err)
return
}
active = owed
}
view := statusView{
Active: active,
ServerTime: rfc3339(now),
}
if res.Event != nil {
view.EventID = res.Event.ID.String()
view.Action = res.Event.Kind
view.Message = deref(res.Event.Message)
view.ServerTime = rfc3339(res.Event.CreatedAt)
}
writeJSON(w, http.StatusOK, view)
}