[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.
This commit is contained in:
2026-10-19 19:34:24 +01:00
parent 0e24c4e4f0
commit ca2130258b
6 changed files with 305 additions and 26 deletions
+35 -14
View File
@@ -63,15 +63,19 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
validationError(w, "%v", err)
return
}
eventRaw := fieldOrQuery(r, payload, "eventId")
if eventRaw == "" {
validationError(w, "eventId is required")
return
}
eventID, err := uuid.Parse(eventRaw)
if err != nil {
validationError(w, "eventId is not a valid uuid")
return
// eventId is optional on purpose. The native takeover launch carries only the
// alert, so a dismissal from that path has no event to name; "I have heard
// this device's alarm" is then answered by clearing the caller's whole
// outstanding set (see db.AckAllDeliveries). A stale debt that nobody can
// name must not replay forever because the client could not echo a uuid.
var eventID uuid.UUID
if eventRaw := fieldOrQuery(r, payload, "eventId"); eventRaw != "" {
parsed, err := uuid.Parse(eventRaw)
if err != nil {
validationError(w, "eventId is not a valid uuid")
return
}
eventID = parsed
}
alert, err := db.GetAlert(r.Context(), alertID)
@@ -96,12 +100,21 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
}
}
resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()}
resp := ackAlarmResponse{AlertID: alertID.String()}
if eventID != uuid.Nil {
resp.EventID = eventID.String()
}
// A device token lives on the subscription, so the ledger row is addressed
// directly: no user id, no device-id echo, nothing to spoof.
if c.IsDevice {
cleared, err := db.AckDelivery(r.Context(), alertID, c.Sub.ID, eventID)
var cleared int64
var err error
if eventID == uuid.Nil {
cleared, err = db.AckAllDeliveries(r.Context(), alertID, c.Sub.ID)
} else {
cleared, err = db.AckDelivery(r.Context(), alertID, c.Sub.ID, eventID)
}
if err != nil {
dbError(w, action, err)
return
@@ -119,7 +132,8 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
}
resp.Destroyed = destroyed
}
Debug(action, "POST", "alert=%s event=%s acked=%d destroyed=%t (device)", alertID, eventID, resp.Acked, resp.Destroyed)
Debug(action, "POST", "alert=%s event=%s all=%t acked=%d destroyed=%t (device)",
alertID, resp.EventID, eventID == uuid.Nil, resp.Acked, resp.Destroyed)
writeJSON(w, http.StatusOK, resp)
return
}
@@ -132,7 +146,13 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
validationError(w, "%v", err)
return
}
cleared, err := db.AckAlarmDelivery(r.Context(), alertID, c.UserID, deviceid.Hash(raw), eventID)
deviceHash := deviceid.Hash(raw)
var cleared int64
if eventID == uuid.Nil {
cleared, err = db.AckAllForSubscriber(r.Context(), alertID, c.UserID, deviceHash)
} else {
cleared, err = db.AckAlarmDelivery(r.Context(), alertID, c.UserID, deviceHash, eventID)
}
if err != nil {
dbError(w, action, err)
return
@@ -152,7 +172,8 @@ func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
}
}
Debug(action, "POST", "alert=%s event=%s acked=%d destroyed=%t", alertID, eventID, resp.Acked, resp.Destroyed)
Debug(action, "POST", "alert=%s event=%s all=%t acked=%d destroyed=%t",
alertID, resp.EventID, eventID == uuid.Nil, resp.Acked, resp.Destroyed)
writeJSON(w, http.StatusOK, resp)
}
+27 -4
View File
@@ -225,6 +225,12 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
// 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
@@ -238,10 +244,6 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
pending = &rows[0]
}
} else {
deviceHash := ""
if raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
deviceHash = deviceid.Hash(raw)
}
var err error
pending, err = db.PendingTrigger(r.Context(), alertID, c.UserID, deviceHash)
if err != nil && !errors.Is(err, db.ErrNotFound) {
@@ -261,6 +263,27 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
// 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,