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.
300 lines
9.4 KiB
Go
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)
|
|
}
|