b114251f45
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
95 lines
5.1 KiB
Markdown
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.
|