Files
terdut-server/docs/notifications.md
T
Niklas Ye b114251f45 Add notification channels and a provider abstraction
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-12 21:00:40 +02:00

95 lines
5.1 KiB
Markdown

# Notifications
_How pages reach people: channels and providers, ntfy, who gets them and how to acknowledge from the notification._ Back to the [README](../README.md) and the [documentation index](./README.md).
A page reaches a person through their **channels**. A channel is one way of
reaching them: a *provider* (the kind of thing that delivers — `ntfy` today)
and an *address* that provider understands (an ntfy topic). A person can have
several, and a page goes to **every channel that is switched on**, each queued
and retried independently, so one provider being down does not hold back the
others.
## Providers
A provider is configured by the **server**, in its deployment, and is not
something a user picks. Only the kinds the server has been configured with can
be added as a channel: the Account page asks the server (`GET
/api/notify/providers`) and offers just those. With none configured,
notifications are off entirely: no goroutine, and nothing is ever queued.
| Provider | Configured by | Address | Acknowledge button |
|---|---|---|---|
| `ntfy` | `TERDUT_NTFY_URL` (+ `TERDUT_NTFY_TOKEN`) | a topic | yes |
A provider that cannot show a button gets the incident's link in the message
body instead. Adding a provider is one Go type in `internal/notify` and a line
in `cmd/terdut/main.go`; the notifier, queue, API and web UI do not change.
## Channels
Everybody manages their own under *Account* in the web UI: add a channel, send
a **test** through it (the point is that a phone buzzes, and only the person
holding it can tell), turn it off, remove it. `GET|POST
/api/users/{id}/channels` and `PATCH|DELETE .../{channelID}` are the same thing
over the API, and an administrator may manage somebody else's, but never sees
where they go: a channel's **address** is only sent to its owner, because it can
be a secret. An account holds at most 10 channels.
Turning a channel off is the off switch for it: pages skip it, including the
reminders of an incident that already paged it. A user with no usable channel
falls back to the install's fallback target (`TERDUT_NTFY_FALLBACK_TOPIC`), as
does an incident that opens with nobody on call. If neither yields a target,
nothing is queued.
An ntfy topic is a shared secret with the ntfy server: anyone who knows it can
both read the pages and publish to it, so an unguessable one is worth the
trouble. That is also why an address never appears in an incident's timeline,
which every API key can read — the timeline names the channel *type* only.
Three things get sent:
- **triggered** — an incident opened. Urgency follows severity (on ntfy,
`critical` maps to max priority, the one that overrides the phone's quiet
settings).
- **reminder** — the incident is still `triggered` after `TERDUT_NOTIFY_REPEAT`.
Repeats until somebody acts. Acknowledging, snoozing, resolving or archiving
all stop it — snooze is the mute button.
- **resolved** — every alert under the incident stopped firing. Only sent to
whoever was paged in the first place, and only for the automatic cascade:
resolving by hand pushes nothing, since the person who did it already knows.
Notifications on a provider that can show one carry an **Acknowledge** button
that acknowledges the incident without opening anything. It POSTs to `/api/notify/ack/{token}`, an
unauthenticated route authorised by the 256-bit token in its path — minted fresh
per notification, scoped to one incident and one action, and valid for 24 hours.
A real API key is never put in a notification, because the message is stored on
the provider's server and cached on the device.
The token is **not** consumed by use. Acknowledging is idempotent, so a token
stays valid for its full 24 hours and a second tap is a no-op that reports the
incident's current state rather than an error — which is what you want when a
tap is retried on a flaky mobile connection. What bounds it is scope, not a use
count: one incident, one action, one day. Expired tokens are purged by the
sweeper.
Two consequences worth planning for:
- `/api/notify/ack/{token}` **must stay publicly reachable**, or the button will
not work when the responder is off your network.
- Notifications sent to the fallback target carry **no** Acknowledge button. The
target is shared, and a button on it would let any recipient acknowledge as
somebody else.
Delivery is a queue, not an inline call: the webhook writes a row per channel
and a background notifier sends it within 30 seconds, retrying with exponential
backoff up to 8 attempts. Nothing about ingestion blocks on a provider being
reachable. Each row keeps a snapshot of its address, so a message already
queued still goes out if the channel is edited or removed; later reminders and
the all-clear follow the channels as they are then.
Every delivery is recorded on the incident's timeline: a `notified` event once
the provider accepts it (`triggered via ntfy`), and a `notify_failed` event when
a notification exhausts its retries. Written from the result rather than at enqueue, so the
timeline says what actually happened — and a page that never landed is visible
instead of looking the same as one that did.