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
This commit is contained in:
+60
-28
@@ -1,27 +1,56 @@
|
||||
# Push notifications
|
||||
# Notifications
|
||||
|
||||
_Pages through ntfy, who gets them and how to acknowledge from the notification._ Back to the [README](../README.md) and the [documentation index](./README.md).
|
||||
_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).
|
||||
|
||||
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
|
||||
person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic
|
||||
under *Account* in the web UI, where a **Send a test push** button proves it
|
||||
before an incident has to; `PUT /api/users/{id}/notify` is the same thing over
|
||||
the API, and an administrator may set somebody else's. A user with no topic
|
||||
falls back to `TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with
|
||||
nobody on call. If neither yields a topic, nothing is queued.
|
||||
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.
|
||||
|
||||
The **server** is the install's one ntfy, from `TERDUT_NTFY_URL`, and is not
|
||||
something a user picks. Only the topic is per-person.
|
||||
## Providers
|
||||
|
||||
A 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 the topic never appears in an incident's timeline, which every
|
||||
API key can read.
|
||||
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.
|
||||
|
||||
Three things get pushed:
|
||||
| Provider | Configured by | Address | Acknowledge button |
|
||||
|---|---|---|---|
|
||||
| `ntfy` | `TERDUT_NTFY_URL` (+ `TERDUT_NTFY_TOKEN`) | a topic | yes |
|
||||
|
||||
- **triggered** — an incident opened. Priority follows severity (`critical` maps
|
||||
to ntfy's max priority, the one that overrides the phone's quiet settings).
|
||||
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.
|
||||
@@ -29,12 +58,12 @@ Three things get pushed:
|
||||
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 carry an **Acknowledge** button that acknowledges the incident
|
||||
without opening anything. It POSTs to `/api/notify/ack/{token}`, an
|
||||
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 ntfy server and cached on the device.
|
||||
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
|
||||
@@ -47,16 +76,19 @@ 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 topic carry **no** Acknowledge button. The
|
||||
topic is shared, and a button on it would let any subscriber acknowledge as
|
||||
- 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 and a
|
||||
background notifier sends it within 30 seconds, retrying with exponential
|
||||
backoff up to 8 attempts. Nothing about ingestion blocks on ntfy being reachable.
|
||||
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
|
||||
ntfy accepts the publish, and a `notify_failed` event when a notification
|
||||
exhausts its retries. Written from the result rather than at enqueue, so the
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user