[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:
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user