// 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 . 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) }