[refactor](alarm): end alerts by disarm or expiry, not a ring timer
An alert now rings from trigger until the owner disarms it; a subscriber silences only its own device. The owner instead sets a persistence cap (default 24h, hard cap 14 days) and the sweeper destroys expired alerts. Disarm ends an armed alert too, and every ending (delivered, expired, disarmed, deleted) converges on the one destruction path so their event-log reasons cannot drift.
This commit is contained in:
+74
-83
@@ -17,6 +17,7 @@ package actions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -134,32 +135,30 @@ type alertView struct {
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
AutoStopSeconds int `json:"autoStopSeconds"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
Created string `json:"created"`
|
||||
Modified string `json:"modified"`
|
||||
TriggeredAt *string `json:"triggeredAt,omitempty"`
|
||||
DisarmedAt *string `json:"disarmedAt,omitempty"`
|
||||
QRPayload string `json:"qrPayload"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
SubscriptionCount int `json:"subscriptionCount"`
|
||||
LastEvent *eventView `json:"lastEvent,omitempty"`
|
||||
}
|
||||
|
||||
func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event, now time.Time) alertView {
|
||||
func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event) alertView {
|
||||
v := alertView{
|
||||
AlertID: a.ID.String(),
|
||||
Name: a.Name,
|
||||
Message: deref(a.Message),
|
||||
Status: a.Status,
|
||||
Active: alertstate.IsActive(a.Status, a.TriggeredAt, a.AutoStopSeconds, now),
|
||||
AutoStopSeconds: a.AutoStopSeconds,
|
||||
TokenVersion: a.TokenVersion,
|
||||
Active: alertstate.IsActive(a.Status),
|
||||
Created: rfc3339(a.Created),
|
||||
Modified: rfc3339(a.Modified),
|
||||
TriggeredAt: rfc3339Ptr(a.TriggeredAt),
|
||||
DisarmedAt: rfc3339Ptr(a.DisarmedAt),
|
||||
QRPayload: qrpayload.Build(a.ID, a.SubscribeToken),
|
||||
ExpiresAt: rfc3339(a.ExpiresAt),
|
||||
IsAdmin: isAdmin,
|
||||
SubscriptionCount: count,
|
||||
}
|
||||
@@ -177,23 +176,25 @@ func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event, now time.T
|
||||
// ---- createAlert ------------------------------------------------------------
|
||||
|
||||
type createAlertRequest struct {
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
AutoStopSeconds *int `json:"autoStopSeconds"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
// ExpiresInSeconds is the owner's requested persistence cap. Omitted takes
|
||||
// DefaultAlertExpiry; anything above MaxAlertExpiry is refused rather than
|
||||
// clamped, because a silent clamp changes what the owner asked for.
|
||||
ExpiresInSeconds *int64 `json:"expiresInSeconds"`
|
||||
}
|
||||
|
||||
type createAlertResponse struct {
|
||||
AlertID string `json:"alertId"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
AdminToken string `json:"adminToken"`
|
||||
SubscribeToken string `json:"subscribeToken"`
|
||||
QRPayload string `json:"qrPayload"`
|
||||
AutoStopSeconds int `json:"autoStopSeconds"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
Created string `json:"created"`
|
||||
AlertID string `json:"alertId"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
AdminToken string `json:"adminToken"`
|
||||
SubscribeToken string `json:"subscribeToken"`
|
||||
QRPayload string `json:"qrPayload"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// CreateAlert implements POST /action/createAlert.
|
||||
@@ -222,15 +223,6 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
validationError(w, "%v", err)
|
||||
return
|
||||
}
|
||||
autoStop := 0
|
||||
if req.AutoStopSeconds != nil {
|
||||
autoStop = *req.AutoStopSeconds
|
||||
}
|
||||
if err := validateAutoStop(autoStop); err != nil {
|
||||
validationError(w, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
adminToken, err := alarmtoken.NewAdminToken()
|
||||
if err != nil {
|
||||
Error(action, "random", "%v", err)
|
||||
@@ -244,37 +236,42 @@ func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
return
|
||||
}
|
||||
|
||||
ttl, err := resolveAlertExpiry(req.ExpiresInSeconds)
|
||||
if err != nil {
|
||||
validationError(w, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var msgPtr *string
|
||||
if strings.TrimSpace(message) != "" {
|
||||
msgPtr = &message
|
||||
}
|
||||
|
||||
alert, err := db.CreateAlert(r.Context(), &db.Alert{
|
||||
AdminUserID: p.ID,
|
||||
Name: name,
|
||||
Message: msgPtr,
|
||||
AdminTokenHash: alarmtoken.Hash(adminToken),
|
||||
SubscribeToken: subscribeToken,
|
||||
AutoStopSeconds: autoStop,
|
||||
AdminUserID: p.ID,
|
||||
Name: name,
|
||||
Message: msgPtr,
|
||||
AdminTokenHash: alarmtoken.Hash(adminToken),
|
||||
SubscribeToken: subscribeToken,
|
||||
ExpiresAt: time.Now().UTC().Add(ttl),
|
||||
})
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
}
|
||||
|
||||
Debug(action, "POST", "alert=%s admin=%s autoStop=%d", alert.ID, p.ID, autoStop)
|
||||
Debug(action, "POST", "alert=%s admin=%s", alert.ID, p.ID)
|
||||
writeJSON(w, http.StatusOK, createAlertResponse{
|
||||
AlertID: alert.ID.String(),
|
||||
Name: alert.Name,
|
||||
Message: deref(alert.Message),
|
||||
Status: alert.Status,
|
||||
Active: false,
|
||||
AdminToken: adminToken,
|
||||
SubscribeToken: alert.SubscribeToken,
|
||||
QRPayload: qrpayload.Build(alert.ID, alert.SubscribeToken),
|
||||
AutoStopSeconds: alert.AutoStopSeconds,
|
||||
TokenVersion: alert.TokenVersion,
|
||||
Created: rfc3339(alert.Created),
|
||||
AlertID: alert.ID.String(),
|
||||
Name: alert.Name,
|
||||
Message: deref(alert.Message),
|
||||
Status: alert.Status,
|
||||
Active: false,
|
||||
AdminToken: adminToken,
|
||||
SubscribeToken: alert.SubscribeToken,
|
||||
QRPayload: qrpayload.Build(alert.ID, alert.SubscribeToken),
|
||||
ExpiresAt: rfc3339(alert.ExpiresAt),
|
||||
Created: rfc3339(alert.Created),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -317,7 +314,7 @@ func GetAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, newAlertView(res.Alert, isAdmin, count, res.Event, time.Now().UTC()))
|
||||
writeJSON(w, http.StatusOK, newAlertView(res.Alert, isAdmin, count, res.Event))
|
||||
}
|
||||
|
||||
// ---- deleteAlert ------------------------------------------------------------
|
||||
@@ -332,8 +329,7 @@ func DeleteAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if !alertstate.CanDelete(alertstate.IsActive(alert.Status, alert.TriggeredAt, alert.AutoStopSeconds, now)) {
|
||||
if !alertstate.CanDelete(alertstate.IsActive(alert.Status)) {
|
||||
writeError(w, http.StatusConflict, CodeAlertState, "disarm the alert before deleting it")
|
||||
return
|
||||
}
|
||||
@@ -348,41 +344,36 @@ func DeleteAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- rotateSubscribeToken ---------------------------------------------------
|
||||
// Persistence-cap bounds. The user's rule: the owner picks how long the alert
|
||||
// lives, hard maximum 14 days; after that, clients that never came back are
|
||||
// ignored and the alert is destroyed with everything about it.
|
||||
//
|
||||
// Chosen numbers (documented in API.md and reported to the lead):
|
||||
// - maximum 14 days — the user's hard cap, refused above rather than clamped;
|
||||
// - default 24 hours — an alert is a transient safety mechanism, and a day is
|
||||
// the smallest window that still covers "created this evening, triggered
|
||||
// tomorrow morning" without leaving debris for a week;
|
||||
// - minimum 1 minute — sweeper granularity is ~60s, and anything shorter would
|
||||
// be expired before a device could plausibly join.
|
||||
const (
|
||||
DefaultAlertExpiry = 24 * time.Hour
|
||||
MinAlertExpiry = time.Minute
|
||||
MaxAlertExpiry = 14 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type rotateResponse struct {
|
||||
AlertID string `json:"alertId"`
|
||||
SubscribeToken string `json:"subscribeToken"`
|
||||
QRPayload string `json:"qrPayload"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
}
|
||||
|
||||
// RotateSubscribeToken implements POST /action/rotateSubscribeToken. Existing
|
||||
// subscriptions survive: rotation invalidates the QR/join path, not the people
|
||||
// who already joined.
|
||||
func RotateSubscribeToken(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
const action = "rotateSubscribeToken"
|
||||
alert, _, ok := requireAdminToken(w, r, action, payload)
|
||||
if !ok {
|
||||
return
|
||||
// resolveAlertExpiry validates the requested duration. A nil request takes the
|
||||
// default; out-of-range values are ERR_VALIDATION rather than a silent clamp.
|
||||
func resolveAlertExpiry(seconds *int64) (time.Duration, error) {
|
||||
if seconds == nil {
|
||||
return DefaultAlertExpiry, nil
|
||||
}
|
||||
|
||||
token, err := alarmtoken.NewSubscribeToken()
|
||||
if err != nil {
|
||||
Error(action, "random", "%v", err)
|
||||
writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate a token")
|
||||
return
|
||||
ttl := time.Duration(*seconds) * time.Second
|
||||
if ttl < MinAlertExpiry {
|
||||
return 0, fmt.Errorf("expiresInSeconds must be at least %d (1 minute)", int64(MinAlertExpiry.Seconds()))
|
||||
}
|
||||
version, err := db.RotateSubscribeToken(r.Context(), alert.ID, token)
|
||||
if err != nil {
|
||||
dbError(w, action, err)
|
||||
return
|
||||
if ttl > MaxAlertExpiry {
|
||||
return 0, fmt.Errorf("expiresInSeconds must be at most %d (14 days, the hard cap)",
|
||||
int64(MaxAlertExpiry.Seconds()))
|
||||
}
|
||||
Debug(action, "POST", "alert=%s token rotated to version %d", alert.ID, version)
|
||||
writeJSON(w, http.StatusOK, rotateResponse{
|
||||
AlertID: alert.ID.String(),
|
||||
SubscribeToken: token,
|
||||
QRPayload: qrpayload.Build(alert.ID, token),
|
||||
TokenVersion: version,
|
||||
})
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
+98
-42
@@ -16,22 +16,23 @@
|
||||
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"`
|
||||
AutoStopSeconds int `json:"autoStopSeconds"`
|
||||
Delivered int `json:"delivered"`
|
||||
EventID string `json:"eventId"`
|
||||
Action string `json:"action"`
|
||||
ServerTime string `json:"serverTime"`
|
||||
Delivered int `json:"delivered"`
|
||||
}
|
||||
|
||||
type triggerRequest struct {
|
||||
@@ -76,23 +77,21 @@ func TriggerAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
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),
|
||||
AutoStopSeconds: updated.AutoStopSeconds,
|
||||
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),
|
||||
AutoStopSeconds: updated.AutoStopSeconds,
|
||||
Delivered: delivered,
|
||||
EventID: event.ID.String(),
|
||||
Action: alertstate.KindTrigger,
|
||||
ServerTime: rfc3339(event.CreatedAt),
|
||||
Delivered: delivered,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,41 +112,79 @@ func DisarmAlert(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
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),
|
||||
AutoStopSeconds: updated.AutoStopSeconds,
|
||||
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),
|
||||
AutoStopSeconds: updated.AutoStopSeconds,
|
||||
Delivered: delivered,
|
||||
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"`
|
||||
AutoStopSeconds int `json:"autoStopSeconds"`
|
||||
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.
|
||||
// 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 (status + auto-stop window), never stored, so no
|
||||
// background job is needed to expire an alarm.
|
||||
// `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"
|
||||
p, ok := principal(w, r, action)
|
||||
@@ -175,8 +212,28 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
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 raw := strings.TrimSpace(r.URL.Query().Get("deviceId")); raw != "" {
|
||||
deviceHash = deviceid.Hash(raw)
|
||||
}
|
||||
pending, err := db.PendingTrigger(r.Context(), alertID, p.ID, 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, res.Alert.TriggeredAt, res.Alert.AutoStopSeconds, now)
|
||||
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".
|
||||
@@ -184,9 +241,8 @@ func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) {
|
||||
}
|
||||
|
||||
view := statusView{
|
||||
Active: active,
|
||||
ServerTime: rfc3339(now),
|
||||
AutoStopSeconds: res.Alert.AutoStopSeconds,
|
||||
Active: active,
|
||||
ServerTime: rfc3339(now),
|
||||
}
|
||||
if res.Event != nil {
|
||||
view.EventID = res.Event.ID.String()
|
||||
|
||||
Reference in New Issue
Block a user