# Admin notifications

> Turn domain events into operator alerts: an always-on in-app inbox plus fan-out to Slack, Discord, Apprise and email, with per-event message templates, deep links and an SSRF-guarded webhook path.

Slicekit ships the alerting layer most templates leave to you. Twenty catalogued events, from a new
sign-up to a cancelled subscription or a failed payment, are recorded in an **in-app inbox** and
optionally fanned out to **Slack, Discord, Apprise or email**. Admins choose which events reach which
target, and rewrite the message per event in a builder that previews the real payload.

The inbox is always written; only fan-out is optional. That is what makes the panel trustworthy: an
event is recorded even when nothing is subscribed to it, so the inbox is a complete history rather
than a copy of whatever happened to be wired up at the time.

## The event catalog

Events are grouped into five categories, which drive grouping and presentation (the Discord embed
colour, the Apprise `type`, inbox filtering) rather than delivery.

| Category | Events |
|---|---|
| **Users** | registered, email verified, email changed, deleted |
| **Billing** | subscription started, plan changed, subscription canceled, invoice paid, invoice payment failed |
| **Security** | failed-login burst, user locked out, token theft detected, TOTP disabled |
| **Admin** | admin status changed, user enabled/disabled, impersonation started, permissions changed |
| **Ops** | Stripe webhook failed, scheduled job failed, notification delivery failed |

Most events carry a **deep link** into the admin panel, so an alert about a user is one click away
from that user's detail page.

Adding your own event is a catalog entry plus a handler method. The catalog is data, and a startup
sync service seeds the settings row for any new key, so nothing else needs touching. A unit test
fails the build if a default template uses a token the definition does not declare.

## Message templates

Templates are `{{token}}` substitution, resolved per (target, event) with the subscription override
falling back to the catalog default.

Escaping applies to **token values, never the template**. An admin who writes `*{{email}} signed up*`
gets Slack bold around a safely escaped address; escaping the template would destroy the formatting
they typed on purpose. Unknown tokens render literally and come back as warnings at authoring time,
because a typo must not stop an alert from going out.

The preview endpoint renders through the **same code path delivery uses**, so what the builder shows
and what arrives cannot drift.

## Delivery and failure

Delivery runs through the outbox as its own command, so the inbox row and the delivery commands
commit together or not at all. Each delivery re-checks that it is still `Pending` before dispatching,
which is the idempotency gate that makes at-least-once redelivery safe.

| Outcome | Handling |
|---|---|
| 2xx | Marked succeeded |
| 5xx, 408, 429, timeout, connection failure | Retried with a cooldown ladder, then dead-lettered |
| Other 4xx | Marked failed, no retry. A 404 webhook will never become a 200 |

A permanent failure raises its own `ops.notification_delivery_failed` event flagged **inbox-only**.
Without that flag, a dead Slack webhook would generate a failure notice, delivered to the dead
webhook, forever. A maintenance ticker reaps deliveries stuck past their attempt or time budget and
reports once per target rather than once per message.

Per-target rate limiting means a burst is `Skipped` with a reason recorded, never silently dropped.

## Deduplication

Repeated occurrences of the same event collapse into one inbox row with an occurrence count instead
of a wall of duplicates. The dedupe key is stored bucketed by window, behind a **unique filtered
index**.

That index is doing real work. Two copies of one domain event can be processed off the outbox
concurrently, and a plain check-then-insert races: both handlers see no row, and both insert. The
index rejects the loser, which then folds into the winner's row. A verification link double-submitted
in the same window produces one row with a count of two, not two rows.

## Security

Admin-supplied webhook URLs are an SSRF primitive if left unguarded. Slicekit rejects non-HTTP(S)
schemes and hosts that resolve to loopback, link-local or private ranges, with an optional host
allowlist to narrow it further. The guard runs at target create/update **and again at dispatch**, so
DNS rebinding does not slip past it.

Endpoints are encrypted at rest and never returned to a client. Target responses carry only a masked
hint such as `hooks.slack.com/.../AbC1`, and saving a target without an endpoint keeps the stored
value, so the UI never has to echo a secret back to submit a form.

Access is gated by three permissions covering read-only viewing, managing targets, and managing the
inbox. Notification records are excluded from the audit trail on purpose, since auditing them would
emit a line per alert raised, delivered and read; target and subscription changes stay audited.

## Personal data

Notification reads are exported and deleted with the account. Notifications *about* a deleted user
keep their row as the administrator's operational trail, but the personal data inside them is
redacted and the link to the account is dropped. The architecture test that keeps export and erasure
in step covers these records too, so this cannot quietly rot.
