docs(website): the in-app channel as built (engagement Phase 7)
ENGAGEMENT.md gains the Phase 7 as-built: the four decisions the org lead settled before any code (in-app defaults to `instant`; the phase takes push's `deliver` and the web preferences screen; the inbox takes `/notifications` and the settings move under it; `ctx.inbox.push` respects a preference where one exists), the block-role mapping that turns a template into a row, and five things the tree contradicted or the build found — including the one only the live rig could see, that staff had no reachable inbox at all. Two earlier passages amended where the phase made them false: Phase 2's "`ctx.inbox.push` throws until Phase 7" and Phase 3's "`inapp` is declared `off`". Both kept as history with the correction beside them. BACKEND_DESIGN.md gains the four inbox routes in the `/auth/me` table and a `user_notifications` entry in the table inventory — the user-scoped dedupe index, why `body` is text rather than the email HTML, and the retention policy. Code: RunicGateway/website#TBD Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -680,6 +680,45 @@ to the in-code seed whenever the row is absent or its `blocks` will not parse
|
||||
to the in-code seed whenever the row is absent or its `blocks` will not parse — before the first seed
|
||||
runs, after a restore that dropped the table, or on a row hand-edited in the database. That fallback is
|
||||
what makes it safe for a password-reset mail to depend on this table at all.
|
||||
|
||||
### user_notifications — the in-app inbox (engagement phase 7)
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | BIGINT AUTO_INCREMENT PK | |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | CASCADE, unlike `engagement_sends`: this is content addressed to a person, not an audit of what the deployment sent |
|
||||
| trigger_id | VARCHAR(96) NOT NULL | denormalized, **no foreign key** — a trigger is declared in code |
|
||||
| title | VARCHAR(300) NOT NULL | rendered from the template's first `email.heading`; falls back to the projected `title`, then to the key. Truncated rather than refused |
|
||||
| body | TEXT NULL | the **text** render of the template's remaining blocks. Not the email HTML — see below |
|
||||
| url | VARCHAR(500) NULL | **site-relative only**, validated with the same character class `pageUrlTemplate` and the engine's `url` variables use. An absolute url on this deployment's own base is reduced to a relative one; anything else is dropped to NULL |
|
||||
| dedupe_key | VARCHAR(190) NULL | NULL = this item does not dedupe |
|
||||
| read_at | DATETIME NULL | |
|
||||
| created_at | DATETIME | |
|
||||
|
||||
`UNIQUE (user_id, dedupe_key)`, `INDEX(user_id, read_at, created_at)`, `INDEX(created_at)`.
|
||||
|
||||
**The unique key is scoped to the USER, and that is deliberately narrower than the outbox's.**
|
||||
`engagement_outbox` scopes its dedupe to `(rule, user, channel)` because one event legitimately becomes
|
||||
one row per channel; an inbox has no channel dimension, so two rows for one event would be one item
|
||||
shown twice. Multiple NULLs are permitted by a UNIQUE index, which is what "does not dedupe" means, and
|
||||
`INSERT IGNORE` is what makes a replay, a retry and a module writing the same item twice all one no-op.
|
||||
|
||||
**`body` is text, and that is the load-bearing choice rather than a shortcut.** The `email.*` renderer
|
||||
produces markup built for mail clients — table rows, inline hex colours, a light-only palette declared
|
||||
with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a pale card
|
||||
floating in a dark one. `toText` is the same content with none of that, and it is the part the block
|
||||
contract already promises every block can produce. It also means there is no operator markup on this
|
||||
surface to sanitize, and no way for one to appear: every renderer treats the column as text.
|
||||
|
||||
**The template maps onto the three columns by block ROLE** (`templates.renderInappByKey`): the first
|
||||
`email.heading` is the title, the first `email.button` is the url, and everything else is the body. So
|
||||
an operator editing `inapp.event` in the Phase 5b editor changes what appears in the inbox, which is the
|
||||
only reason the template exists at all.
|
||||
|
||||
**Retention: `utils/userNotificationsPrune.js`, nightly, READ items only.** Age alone would delete the
|
||||
evidence for "I was never told", which is the complaint this table answers, and an inbox that quietly
|
||||
drops unread items is one whose badge means nothing. The horizon is `settings.user_notifications_retain_days`
|
||||
(default 90), so an operator tightens a busy shard without a deploy — `team_activity`'s posture, in the
|
||||
worker that file is modelled on.
|
||||
|
||||
### The two block registries — pages and mail (engagement phase 5a)
|
||||
|
||||
@@ -1098,6 +1137,10 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
|
||||
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
|
||||
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
|
||||
| GET · PUT | `/me/notifications/channels` | cookie / bearer | `{prefs:[{id,channel,mode}]}` on PUT | get / update own **per-channel** preferences ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5, phase 3). Returns the delivery-channel registry (`email`/`push`/`inapp`, each with `defaultMode`, `supportsDigest`, `modes`) plus one item per subscribable id — the **union** of push streams and event triggers, one namespace (§7.2) — carrying the **effective** mode on each channel that applies to it. A trigger-only id has no `push` toggle; a mode with no stored row reads as that channel’s default, so a client never sees which is which. The PUT is **sparse**: only the `(id, channel)` pairs listed are written and every other pair is untouched, so setting `email` cannot disturb `push`. `off` is a mode, never an omission — which is why this endpoint has no required-empty-array case. Entries naming an unknown id, an inapplicable channel or a mode that channel does not accept are **dropped, not refused**; the full stored state is echoed back. A `push` entry is mirrored into `/me/notifications/subscriptions`, whose wire shape is unchanged |
|
||||
| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
|
||||
| GET | `/me/notifications` | cookie / bearer | `?limit&before&unread` | **one page of the caller's in-app inbox** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5 G17, phase 7), newest first. `before` is a **keyset cursor** (the previous page's last id), never an offset: the list gains rows at the top while it is being read. `limit` defaults to 30, capped at 100. Carries `unread`, the count for the whole inbox rather than the page, so a client rendering both a list and a badge cannot show them disagreeing. **No parameter names a user** — the caller is the only account any of these four routes can read |
|
||||
| GET | `/me/notifications/unread-count` | cookie / bearer | — | `{unread}`. Its own route because it is **polled**: asking "is there anything new" must not make the server assemble a page of bodies to answer with one integer |
|
||||
| POST | `/me/notifications/:id/read` | cookie / bearer | — | mark one item read. **Idempotent** — the statement carries `read_at IS NULL`, so a second call does not move the stamp. **404 both** when no such item exists and when it belongs to another account: the same answer on purpose, so this cannot be used to ask whether an id is anybody's |
|
||||
| POST | `/me/notifications/read-all` | cookie / bearer | — | mark the whole inbox read; returns `{ok, changed, unread:0}` |
|
||||
|
||||
**Role-agnostic self-service (`/auth/me/*`).** The **only** self-service account surface, for every
|
||||
|
||||
Reference in New Issue
Block a user