[feat](alarm): add a durable per-device delivery ledger

pg_notify and the socket fan-out are not durable, so an offline phone used to miss a trigger forever and no-auto-stop would leave it ringing at nobody. Write one ledger row per enabled subscription in the trigger transaction, replay what a subscription still owes on every socket connect, and clear a row only on an explicit ack (live socket ack or ackAlarm); alarmStatus is a read-only check-in that answers 409 ERR_ALARM_TRIGGERED_OFFLINE with the missed frame. Ack is idempotent, and a destroyed alert cascades its debt away so a client is never told it missed an alarm that no longer exists.
This commit is contained in:
2026-10-19 02:26:46 +01:00
parent 0334d7fe93
commit 6e6a1e9c8a
12 changed files with 1128 additions and 77 deletions
+136
View File
@@ -0,0 +1,136 @@
// 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 (
"net/http"
"strings"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/deviceid"
"github.com/google/uuid"
)
// ackAlarmRequest is the HTTP dismissal of a ringing alarm. The device rides the
// payload (or the query string) exactly like joinAlert's, so the caller is
// addressed by (alert, user, device) and can only clear its own debt.
type ackAlarmRequest struct {
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
DeviceID string `json:"deviceId"`
}
type ackAlarmResponse struct {
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
Acked int64 `json:"acked"`
Destroyed bool `json:"destroyed"`
}
// AckAlarm implements POST /action/ackAlarm.
//
// User ruling: "On client dismiss of a triggered alarm, a request is sent back to
// the server ACKing the alarm, that's what triggers the clear." Clearing is
// therefore ack-driven and never response-driven: the alarmStatus check-in is
// read-only, and this action (or the WebSocket `ack` frame) is what retires the
// delivery-ledger row.
//
// Idempotent: the second ack for the same event clears nothing, returns
// acked:0, and is not an error. When the ack clears the alert's last unacked
// row, the alert is destroyed (reading A) and `destroyed:true` reports it — the
// alert no longer exists, which is the intended outcome, not a failure.
func AckAlarm(w http.ResponseWriter, r *http.Request, payload any) {
const action = "ackAlarm"
p, ok := principal(w, r, action)
if !ok {
return
}
alertID, err := alertIDFrom(r, payload)
if err != nil {
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
}
alert, err := db.GetAlert(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
allowed, err := isAdminOrSubscriber(r, alert, p.ID)
if err != nil {
dbError(w, action, err)
return
}
if !allowed {
writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert")
return
}
resp := ackAlarmResponse{AlertID: alertID.String(), EventID: eventID.String()}
// The owner has no delivery row (only subscribers are owed alarms), so an
// owner ack is a well-formed no-op rather than a validation error.
if alert.AdminUserID != p.ID {
raw, err := validateDeviceID(fieldOrQuery(r, payload, "deviceId"))
if err != nil {
validationError(w, "%v", err)
return
}
cleared, err := db.AckAlarmDelivery(r.Context(), alertID, p.ID, deviceid.Hash(raw), eventID)
if err != nil {
dbError(w, action, err)
return
}
resp.Acked = cleared
if cleared > 0 {
if err := db.RecordDelivered(r.Context(), alertID, p.ID); err != nil {
dbError(w, action, err)
return
}
destroyed, err := db.CompleteAlertDeliveries(r.Context(), alertID)
if err != nil {
dbError(w, action, err)
return
}
resp.Destroyed = destroyed
}
}
Debug(action, "POST", "alert=%s event=%s acked=%d destroyed=%t", alertID, eventID, resp.Acked, resp.Destroyed)
writeJSON(w, http.StatusOK, resp)
}
// fieldOrQuery reads a string from the payload and falls back to the query
// string, matching how the other actions accept both transports.
func fieldOrQuery(r *http.Request, payload any, field string) string {
if v := strings.TrimSpace(r.URL.Query().Get(field)); v != "" {
return v
}
if payload == nil {
return ""
}
return strings.TrimSpace(lenientString(payload, field))
}
+50
View File
@@ -0,0 +1,50 @@
// 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 (
"context"
"errors"
"github.com/google/uuid"
)
// completeAlertDelivery is the decision point for the second half of the
// delivery design: "once every client is removed for an alarm, the alarm is then
// deleted with whichever cascade deletions are relevant".
//
// It is DELIBERATELY UNWIRED — nothing calls it. That sentence has two readings
// and they are different products, so the user has been asked which one is meant:
//
// (a) delete the ALERT. The definition, both tokens, the subscriptions and the
// event history go with it (ON DELETE CASCADE). An alert becomes a
// one-shot event: once every device has acknowledged, it cannot be
// triggered again and the admin token becomes dead.
// (b) retire only the DELIVERY record (and/or the event). The alert survives,
// stays armed, and can be triggered again later; only the ledger bookkeeping
// about this alarm is completed.
//
// Implementing either now would delete or preserve data on a reading nobody
// confirmed, so this function reports that the decision is pending. When the
// answer arrives it gets implemented in exactly one place and called from the
// paths that observe the last outstanding delivery (the alarmStatus check-in and
// the WebSocket ack), where "every client has been told" is decided atomically
// from the ledger rather than from a client's claim.
func completeAlertDelivery(ctx context.Context, alertID uuid.UUID) error {
_ = ctx
_ = alertID
return errors.New("completeAlertDelivery: pending user decision between (a) deleting the alert as a one-shot and (b) retiring only the delivery record")
}
+95
View File
@@ -0,0 +1,95 @@
// 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 (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.dcntrlized.org/Dcntrlized/Alarm/db"
"git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr"
"github.com/google/uuid"
)
// TestWriteOfflineAlarmShape pins the 409 check-in contract: the status, the
// code, and that the body carries a real alarm frame with the SAME event id the
// device missed. This is the one response an integrator must not treat as an
// error, so its shape is not allowed to drift silently.
func TestWriteOfflineAlarmShape(t *testing.T) {
alertID, eventID := uuid.New(), uuid.New()
override := "Leave the building now"
alert := &db.Alert{ID: alertID, Message: &override}
at := time.Date(2026, 10, 18, 17, 5, 0, 123000000, time.UTC)
pending := &db.PendingDelivery{EventID: eventID, Action: "trigger", Message: &override, ServerTime: at}
rec := httptest.NewRecorder()
writeOfflineAlarm(rec, alert, pending)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Alarm struct {
Type string `json:"type"`
AlertID string `json:"alertId"`
EventID string `json:"eventId"`
Action string `json:"action"`
Message string `json:"message"`
ServerTime string `json:"serverTime"`
} `json:"alarm"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body.Error.Code != apierr.AlarmTriggeredOffline {
t.Fatalf("error.code = %q, want %q", body.Error.Code, apierr.AlarmTriggeredOffline)
}
if body.Error.Message == "" {
t.Fatal("error.message must not be empty")
}
if body.Alarm.Type != "alarm" || body.Alarm.Action != "trigger" {
t.Fatalf("alarm frame = %+v, want type=alarm action=trigger", body.Alarm)
}
if body.Alarm.EventID != eventID.String() || body.Alarm.AlertID != alertID.String() {
t.Fatalf("alarm ids = %q/%q, want %q/%q", body.Alarm.AlertID, body.Alarm.EventID, alertID, eventID)
}
if body.Alarm.Message != override {
t.Fatalf("alarm.message = %q, want %q", body.Alarm.Message, override)
}
if body.Alarm.ServerTime != "2026-10-18T17:05:00.123Z" {
t.Fatalf("alarm.serverTime = %q", body.Alarm.ServerTime)
}
}
// TestCompleteAlertDeliveryIsUnwired guards the deliberate hole: the function
// must keep saying "decision pending" rather than quietly deleting or preserving
// data on one reading of the user's sentence.
func TestCompleteAlertDeliveryIsUnwired(t *testing.T) {
if err := completeAlertDelivery(nil, uuid.New()); err == nil {
t.Fatal("completeAlertDelivery must report the pending decision, not succeed")
}
}