Compare commits
4 Commits
2161119c8c
...
docs/engag
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fd5d065b7 | |||
| d9abe3d941 | |||
| 73c3a467e7 | |||
| c93151b352 |
@@ -574,7 +574,8 @@ cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
|
|||||||
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
|
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
|
||||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
|
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
|
||||||
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | |
|
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
|
||||||
|
|
||||||
| scope_key | VARCHAR(190) NULL | what a PREFERENCE and an UNSUBSCRIBE are keyed on (engagement phase 6), e.g. `team:12`. Deliberately **not** `subject_key`: an unsubscribe token is signed over this and sits in a mailbox for months, so it has to be a stable identifier — signing over a display name orphans every link the first time somebody renames a Team. NULL means an unscoped event; `''` is reserved for "deployment-wide" in `engagement_digest_state` |
|
| scope_key | VARCHAR(190) NULL | what a PREFERENCE and an UNSUBSCRIBE are keyed on (engagement phase 6), e.g. `team:12`. Deliberately **not** `subject_key`: an unsubscribe token is signed over this and sits in a mailbox for months, so it has to be a stable identifier — signing over a display name orphans every link the first time somebody renames a Team. NULL means an unscoped event; `''` is reserved for "deployment-wide" in `engagement_digest_state` |
|
||||||
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
|
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
|
||||||
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
|
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
|
||||||
@@ -622,6 +623,31 @@ a rule's budget and mute it.
|
|||||||
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
|
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
|
||||||
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
|
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
|
||||||
counts only.
|
counts only.
|
||||||
|
|
||||||
|
### engagement_digest_state — how far each digest has got (engagement phase 6)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
|
| channel | VARCHAR(32) NOT NULL | |
|
||||||
|
| scope_key | VARCHAR(190) NOT NULL DEFAULT '' | `''` = deployment-wide; `team:12` = one Team. NOT NULL with a `''` default because it is a PRIMARY KEY column and MariaDB coerces a nullable one anyway — the same workaround `team_integration_config` and `teams.active_key` both carry |
|
||||||
|
| last_digest_at | DATETIME NULL | stamped **only on a successful send** |
|
||||||
|
| updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`PRIMARY KEY (user_id, channel, scope_key)`, `INDEX(channel, last_digest_at)` — the worker's driving
|
||||||
|
question is "whose digest is due?", which is a range scan of that index rather than of every digest ever
|
||||||
|
sent.
|
||||||
|
|
||||||
|
**This is a digest's only state, and deliberately not a digest queue.** The engine writes an outbox row
|
||||||
|
per (rule, user, channel) at emit time carrying a snapshot of the payload; that is right for an instant
|
||||||
|
send and wrong for a digest, which is re-derived from the source tables when it goes out. Three
|
||||||
|
properties depend on the re-derivation: a two-day outage sends one digest rather than replaying two
|
||||||
|
days, a post hidden after it was written is not in the query, and — the security one — a user who lost
|
||||||
|
access between the post and the send is no longer in the recipient set. So `engine.subscribedTo`
|
||||||
|
enqueues **`instant` recipients only**.
|
||||||
|
|
||||||
|
Lifted out of `team_notification_prefs.last_digest_at`, which was a worker's column on a user's
|
||||||
|
preferences row; `schema.sql` backfills it with an `INSERT IGNORE … SELECT`, replay-safe by the primary
|
||||||
|
key rather than by a flag.
|
||||||
|
|
||||||
### engagement_templates — the message bodies (engagement phase 5a)
|
### engagement_templates — the message bodies (engagement phase 5a)
|
||||||
| col | type | notes |
|
| col | type | notes |
|
||||||
@@ -654,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
|
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
|
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.
|
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)
|
### The two block registries — pages and mail (engagement phase 5a)
|
||||||
|
|
||||||
@@ -1072,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 | `/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/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/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}` |
|
| 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
|
**Role-agnostic self-service (`/auth/me/*`).** The **only** self-service account surface, for every
|
||||||
@@ -1229,7 +1298,8 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
|
|||||||
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
|
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
|
||||||
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
|
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
|
||||||
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
|
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
|
||||||
| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams |
|
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
|
||||||
|
| POST · GET | `/engagement/unsubscribe/:token` | one-click unsubscribe (engagement phase 6, [`ENGAGEMENT.md`](ENGAGEMENT.md)). **The only write in this tier and the only routes with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC naming a **channel and a scope**, and its whole capability is "turn that channel off for that scope, for one account": it reads nothing, cannot turn anything back on, and names no other scope. POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, scope) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to unsubscribe people who asked for nothing |
|
||||||
| POST · GET | `/teams/unsubscribe/:token` | **the same two handlers, at the path mail sent before phase 6 points at.** Kept permanently: mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. A pre-phase-6 token verifies and reads as `{ channel: 'email', scopeKey: 'team:<id>' }` — it turns that Team's email off and, unlike before, no longer mutes its push |
|
| POST · GET | `/teams/unsubscribe/:token` | **the same two handlers, at the path mail sent before phase 6 points at.** Kept permanently: mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. A pre-phase-6 token verifies and reads as `{ channel: 'email', scopeKey: 'team:<id>' }` — it turns that Team's email off and, unlike before, no longer mutes its push |
|
||||||
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
|
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
|
||||||
|
|
||||||
|
|||||||
@@ -593,7 +593,11 @@ Confirmed, and there is nothing to fix — only something to not break:
|
|||||||
- `pushDispatch.allowedOrigins()` reads `NTFY_ALLOWED_ORIGINS` / `NTFY_BASE_URL` and **returns empty when
|
- `pushDispatch.allowedOrigins()` reads `NTFY_ALLOWED_ORIGINS` / `NTFY_BASE_URL` and **returns empty when
|
||||||
neither is set**. No Runic Gateway host appears anywhere in it.
|
neither is set**. No Runic Gateway host appears anywhere in it.
|
||||||
- `mailer.isConfigured()` gates every sink, and `teamNotify.emailImmediate` checks it *before* the
|
- `mailer.isConfigured()` gates every sink, and `teamNotify.emailImmediate` checks it *before* the
|
||||||
recipient query so an unconfigured deployment pays nothing.
|
recipient query so an unconfigured deployment pays nothing. **As of Phase 6 that sink is the engine's**
|
||||||
|
and the gate moved with it: `mailer.sendNotification` returns a *retryable* failure when mail is
|
||||||
|
unconfigured, so an operator midway through typing SMTP credentials finds the outbox drains rather
|
||||||
|
than a backlog the worker gave up on. The digest worker still checks `isConfigured()` up front, before
|
||||||
|
any query that costs anything.
|
||||||
|
|
||||||
**Rules to carry into the abstraction:**
|
**Rules to carry into the abstraction:**
|
||||||
|
|
||||||
@@ -725,10 +729,20 @@ CREATE TABLE IF NOT EXISTS engagement_digest_state (
|
|||||||
```
|
```
|
||||||
|
|
||||||
`team_notification_prefs.last_digest_at` backfills into this with `channel='email'`,
|
`team_notification_prefs.last_digest_at` backfills into this with `channel='email'`,
|
||||||
`scope_key = team_id`. The three properties from `teamDigestWorker`'s header comment must be preserved
|
`scope_key = CONCAT('team:', team_id)`. The three properties from `teamDigestWorker`'s header comment must
|
||||||
verbatim by the generic worker, and the third one (a user who lost access is no longer in the recipient
|
be preserved verbatim by the generic worker, and the third one (a user who lost access is no longer in the
|
||||||
set) should get its own named test, the way the Teams phase-5 work gave the leader-can't-see-reports rule
|
recipient set) should get its own named test, the way the Teams phase-5 work gave the
|
||||||
its own test.
|
leader-can't-see-reports rule its own test.
|
||||||
|
|
||||||
|
**Phase 6 as built, and it corrects §4.2a rather than only implementing this.** Keeping compute-at-send-time
|
||||||
|
means a digest-mode recipient must get **no outbox row at all**: Phase 4a's `subscribedTo` enqueued them
|
||||||
|
("what changes in Phase 6 is who drains it") and what changed in Phase 6 is that nothing drains it. An
|
||||||
|
outbox row holds a payload snapshotted at emit time and therefore has none of the three properties above —
|
||||||
|
including the security one. The engine now enqueues `instant` only. Two more things landed with it:
|
||||||
|
`engagement_outbox` gained a **`scope_key`** column (what a preference and an unsubscribe are keyed on,
|
||||||
|
which is not `subject_key` — see Phase 6's as-built), and the backfill's replay-safety is a property of the
|
||||||
|
PRIMARY KEY rather than of a flag, so a window the worker has since moved forward is never dragged
|
||||||
|
backwards by a restart.
|
||||||
|
|
||||||
### 4.3 The template variable contract — a code-declared schema, mirrored to a checked-in manifest
|
### 4.3 The template variable contract — a code-declared schema, mirrored to a checked-in manifest
|
||||||
|
|
||||||
@@ -1008,7 +1022,7 @@ UPDATE` keyed on `seed_key`, **and it refuses to overwrite a row whose `customiz
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `notify.event` | the generic single-event mail | `title`, `intro`, `items[]`, `actionUrl`, `unsubscribeUrl` |
|
| `notify.event` | the generic single-event mail | `title`, `intro`, `items[]`, `actionUrl`, `unsubscribeUrl` |
|
||||||
| `notify.digest` | `teamDigestWorker`'s body | `intro`, `periodLabel`, `items[]`, `moreCount`, `scopeUrl`, `unsubscribeUrl` |
|
| `notify.digest` | `teamDigestWorker`'s body | `intro`, `periodLabel`, `items[]`, `moreCount`, `scopeUrl`, `unsubscribeUrl` |
|
||||||
| `notify.team-post` | `mailer.sendTeamNotification` immediate | `teamName`, `authorName`, `threadTitle`, `excerpt`, `threadUrl` |
|
| `notify.team-post` | `mailer.sendTeamNotification` immediate | `teamName`, `authorName`, `threadTitle`, `excerpt`, `postUrl` — **`postUrl`, not `threadUrl`**: it has to be the name `team.forum.post` DECLARES, or the mail links nowhere. Renamed in Phase 6 (`seedVersion` 2) |
|
||||||
| `inapp.event` | *(new)* — the in-app channel's short form | `title`, `body`, `url` |
|
| `inapp.event` | *(new)* — the in-app channel's short form | `title`, `body`, `url` |
|
||||||
|
|
||||||
**Three properties of the seeded set that are design, not packaging:**
|
**Three properties of the seeded set that are design, not packaging:**
|
||||||
@@ -1319,8 +1333,8 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
|
|||||||
| **4b** Rules screen ✅ | `website/BACKEND_DESIGN.md` route table (the twelve routes, incl. the `PATCH …/enabled` argument and the count-only preview) · `website/ENGAGEMENT.md` §5.1a composition UI | Landed with the phase (docs#184) |
|
| **4b** Rules screen ✅ | `website/BACKEND_DESIGN.md` route table (the twelve routes, incl. the `PATCH …/enabled` argument and the count-only preview) · `website/ENGAGEMENT.md` §5.1a composition UI | Landed with the phase (docs#184) |
|
||||||
| **5a** Templates ✅ | `website/ENGAGEMENT.md` §4.6 as built · `BACKEND_DESIGN.md` — the `engagement_templates` table, the two block registries, the token grammar, and §7's multipart/subject changes | Landed with the phase |
|
| **5a** Templates ✅ | `website/ENGAGEMENT.md` §4.6 as built · `BACKEND_DESIGN.md` — the `engagement_templates` table, the two block registries, the token grammar, and §7's multipart/subject changes | Landed with the phase |
|
||||||
| **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`runicgateway.com`**: a new admin docs page for the template editor |
|
| **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`runicgateway.com`**: a new admin docs page for the template editor |
|
||||||
| **6** Email channel + Teams migration | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing | **`runicgateway.com`**: `administration/teams.mdx` notification section |
|
| **6** Email channel + Teams migration ✅ | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing · `website/ENGAGEMENT.md` §4.2b + this phase as built · `BACKEND_DESIGN.md` route table and table inventory | **`runicgateway.com`**: `administration/teams.mdx` notification section. Landed with the phase |
|
||||||
| **7** In-app channel (core+web) | `website/BACKEND_DESIGN.md` routes + tables · `website/ENGAGEMENT.md` | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel |
|
| **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase |
|
||||||
| **8** In-app (Android) | `android/PLAN.md` | `android-app/README.md` |
|
| **8** In-app (Android) | `android/PLAN.md` | `android-app/README.md` |
|
||||||
| **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 |
|
| **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 |
|
||||||
| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
|
| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
|
||||||
@@ -1646,6 +1660,7 @@ mails `uo.cheat.detected` to the player it detected. Fewer people is not less ex
|
|||||||
- `ctx.events.emit` (`utils/engagementEmit.js`) — validate, log, **stop**; throws in dev, drops and
|
- `ctx.events.emit` (`utils/engagementEmit.js`) — validate, log, **stop**; throws in dev, drops and
|
||||||
logs in prod; the owner is bound by core and never read from the arguments
|
logs in prod; the owner is bound by core and never read from the arguments
|
||||||
- `ctx.inbox.push` — present and **throws** until Phase 7, the shape 1.6.0 settled on
|
- `ctx.inbox.push` — present and **throws** until Phase 7, the shape 1.6.0 settled on
|
||||||
|
*(Phase 7 filled it in. Not a version bump: the signature is the one 1.7.0 declared.)*
|
||||||
- `config/coreTriggers.js` — core's five, registered through `registerCore()`
|
- `config/coreTriggers.js` — core's five, registered through `registerCore()`
|
||||||
- `GET /admin/engagement/{triggers,audiences}` — admin-only, served from the registries, no table
|
- `GET /admin/engagement/{triggers,audiences}` — admin-only, served from the registries, no table
|
||||||
- `npm run engagement:manifest` (+ `--check` in CI) and the committed `engagement-triggers.json`
|
- `npm run engagement:manifest` (+ `--check` in CI) and the committed `engagement-triggers.json`
|
||||||
@@ -1731,7 +1746,9 @@ phases before the registry replaced it. What did *not* land is the behavioural h
|
|||||||
`transports/index.js` deferred the whole file in Phase 1. Core's three channels are declared, and
|
`transports/index.js` deferred the whole file in Phase 1. Core's three channels are declared, and
|
||||||
`inapp` is declared `off` for a reason particular to it — the inbox does not exist until Phase 7, and
|
`inapp` is declared `off` for a reason particular to it — the inbox does not exist until Phase 7, and
|
||||||
a default of `instant` would mean every user is opted into a surface with no rows, so the first thing
|
a default of `instant` would mean every user is opted into a surface with no rows, so the first thing
|
||||||
Phase 7 shipped would be a backlog.
|
Phase 7 shipped would be a backlog. **Phase 7 changed it to `instant`** once there was a surface to
|
||||||
|
look at: an inbox item wakes no device and leaves no building, and the backlog this paragraph feared
|
||||||
|
cannot happen against an empty table. See Phase 7's decision 1.
|
||||||
|
|
||||||
**The sparse PUT is the one place this phase leaves the router's idiom, and it buys two things.** A
|
**The sparse PUT is the one place this phase leaves the router's idiom, and it buys two things.** A
|
||||||
whole-set body forces a client that only manages email to send every push row back or wipe them. And
|
whole-set body forces a client that only manages email to send every push row back or wipe them. And
|
||||||
@@ -2335,7 +2352,7 @@ check, because the way to make it pass is to delete the explanation.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 6 — The email channel on the engine, and the Teams migration
|
### Phase 6 — The email channel on the engine, and the Teams migration ✅
|
||||||
|
|
||||||
Email becomes a `DeliveryChannel` driven by rules. `teamNotify.js` and `teamDigestWorker.js` are rewritten
|
Email becomes a `DeliveryChannel` driven by rules. `teamNotify.js` and `teamDigestWorker.js` are rewritten
|
||||||
onto the generic pipeline; `engagement_digest_state` backfills from `team_notification_prefs.last_digest_at`;
|
onto the generic pipeline; `engagement_digest_state` backfills from `team_notification_prefs.last_digest_at`;
|
||||||
@@ -2355,7 +2372,244 @@ replay-safe.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 7 — The in-app channel (core + web)
|
#### As built — 6 (2026-08-29)
|
||||||
|
|
||||||
|
The email channel gained a `deliver`, and the Team pipeline stopped being its own thing. Concretely:
|
||||||
|
`teamNotify.emailImmediate` and `mailer.sendTeamNotification` are gone, `teamNotify.forumPost` emits an
|
||||||
|
event instead, and a rule decides who is mailed. One end-to-end walk on a live rig now goes
|
||||||
|
**forum write → `events.emit` → rule → outbox → worker → email channel → template → SMTP → mailbox**,
|
||||||
|
with the send recorded in `engagement_sends` like everything else the platform sends.
|
||||||
|
|
||||||
|
**Seven decisions the org lead settled before any code**, because the tree contradicted the phase body
|
||||||
|
in every one of them:
|
||||||
|
|
||||||
|
| | Question | Decision |
|
||||||
|
|---|---|---|
|
||||||
|
| Sinks | `teamNotify` has three sinks; which move onto the engine | **email only.** The tickle and the Discord bridge stay direct calls in `teamNotify.js` |
|
||||||
|
| Audience | how a Team event reaches that Team's members, when `members` resolves to nobody and a segment takes constant params | **the event carries the set** — the same access-checked list `teamNotify.recipientIds` has always computed |
|
||||||
|
| Continuity | rules default off and core seeds none, so a straight migration stops Team email silently | **seed the four rules DISABLED**, plus an admin banner and a release note. The invariant is honoured, not excepted |
|
||||||
|
| Prefs | per-Team granularity has nowhere to live in `notification_channel_prefs` | **keep `team_notification_prefs`**, consulted by the engine as a scoped preference |
|
||||||
|
| Templates | §4.6.1 property 1 promises a generic template renders any trigger, and nothing implemented it | **payload wins, a structural projection fills gaps** |
|
||||||
|
| Digest | §4.2b says compute at send time; the engine snapshots at emit time | **keep compute-at-send-time**; generalize only the state |
|
||||||
|
| Unsubscribe | a v1 token set `muted`, silencing push as well as email | **turn off the channel the token names, and nothing else** |
|
||||||
|
|
||||||
|
##### The audience problem, which is the one that shaped the phase
|
||||||
|
|
||||||
|
The engine could not express "the members of the Team this post was in", and the reason is structural
|
||||||
|
rather than an oversight. A rule names its audience two ways: a plain ceiling name resolved from core's
|
||||||
|
own tables, or a **saved segment** composing module-declared audiences with **constant** parameters. A
|
||||||
|
Team forum post needs neither — the recipient list is different for every firing, and it is the answer
|
||||||
|
to an access question (`teamAccess.forumAccess`'s two tables) that core already knows how to ask and a
|
||||||
|
segment has no way to ask at all. `audiences.js` said as much in its own comment: *"core knows no game
|
||||||
|
vocabulary and cannot guess which members were meant."*
|
||||||
|
|
||||||
|
So the **event** names it. `engagementEmit`'s envelope gained `recipientUserIds`, and a rule whose
|
||||||
|
audience is `members` resolves to exactly that set. Three properties make it a narrowing input rather
|
||||||
|
than a hole in the ceiling lattice, and all three are tested:
|
||||||
|
|
||||||
|
- the carried set is still filtered through `users.status = 'active'`, so a banned account is not
|
||||||
|
mailable by an emitter that forgot;
|
||||||
|
- the ceiling returned is still `members`, so **G24 still runs** — a rule cannot be given
|
||||||
|
`authenticated` on a trigger that ceilings at `members`, carried set or no carried set;
|
||||||
|
- the list is bounded at `MAX_AUDIENCE` (5000) *at the emit boundary*, so an emitter cannot assert an
|
||||||
|
audience larger than the engine would have loaded from a query.
|
||||||
|
|
||||||
|
**A rule with any other audience ignores it entirely.** This is not "the emitter decides who gets
|
||||||
|
mailed": it is the emitter answering the one question core cannot, and the rule deciding everything
|
||||||
|
else.
|
||||||
|
|
||||||
|
##### `scope_key` is not `subject_key`, and Phase 6 is where that stopped being theoretical
|
||||||
|
|
||||||
|
Both are per-event strings on the outbox row and they are keyed on different things:
|
||||||
|
|
||||||
|
- **`subject_key`** is what a **cooldown** counts. It comes from the trigger's declared `subjectKey`,
|
||||||
|
which for all four Team triggers is `teamName` — a display string, which is fine, because a cooldown
|
||||||
|
only ever compares it with itself.
|
||||||
|
- **`scope_key`** is what a **preference** and an **unsubscribe** are keyed on, and it has to be a
|
||||||
|
stable identifier. `team:12` survives a rename; `The Silver Anvil` does not. Signing an unsubscribe
|
||||||
|
token over a display name would orphan every link in every mailbox the first time staff renamed a
|
||||||
|
guild — and those links have no expiry, so "the first time" means "ever".
|
||||||
|
|
||||||
|
`engagement_digest_state.scope_key` already used that vocabulary in §4.2b, so the outbox column is the
|
||||||
|
same vocabulary in the same shape rather than a second one.
|
||||||
|
|
||||||
|
##### The scoped preference, and the one place the decision as phrased could not ship
|
||||||
|
|
||||||
|
Decision 4 was recorded as *"a suppression below the channel preference"*. Building it showed that
|
||||||
|
reading is the one that cannot ship, and the reason is worth stating because it is a second instance of
|
||||||
|
the G22 pattern — a migration that degrades silently:
|
||||||
|
|
||||||
|
`notification_channel_prefs` holds a row **only where a user has expressed something**; absence means
|
||||||
|
the channel's `defaultMode`; and email's is `off`. Nobody has ever expressed a stream-level opinion
|
||||||
|
about `team.forum.post` — the screen that would let them is Phase 3's and the preference predates it by
|
||||||
|
a year. So intersecting the two preferences would resolve **every existing Team-email subscriber** to
|
||||||
|
`off`, and the deploy that migrated the pipeline would be the deploy that silenced it.
|
||||||
|
|
||||||
|
What shipped is **replacement**: where a scope has an opinion, that opinion is the preference.
|
||||||
|
`engagement/scopedPrefs.js` is a small registry keyed on the scope-key prefix; `engagement/coreScopePrefs.js`
|
||||||
|
registers `team` and maps the two columns:
|
||||||
|
|
||||||
|
- `muted` → `off` **on every channel**. That is what the toggle has always meant on the account screen,
|
||||||
|
and narrowing it to email would be a behaviour change nobody asked for.
|
||||||
|
- `email_mode` → the engine's vocabulary (`immediate` → `instant`), and **only for the email channel**.
|
||||||
|
On push or in-app the provider returns no opinion and the stream-level preference decides.
|
||||||
|
- **absence of a row means `off` for email**, which is why the provider answers for every user in the
|
||||||
|
set rather than only the rows it finds. The column defaults to `'off'` and both recipient queries
|
||||||
|
COALESCE to it: no row has always meant "has not asked for Team email". Deferring to the stream-level
|
||||||
|
preference instead would mean somebody who once switched on `team.forum.post` email in the channels
|
||||||
|
screen starts receiving mail from **every Team on the deployment** — a widening produced by a
|
||||||
|
migration, of a preference expressed about something else.
|
||||||
|
|
||||||
|
The cost, stated so nobody rediscovers it: a user cannot turn Team email off for every Team at once
|
||||||
|
from the channels screen. That control lives on the per-Team screen, which is where it has always lived
|
||||||
|
and where the unsubscribe link points.
|
||||||
|
|
||||||
|
The provider **fails open** — a lookup that throws leaves the stream-level preference in charge, which
|
||||||
|
for every core channel is `off`. So a failure means nothing is sent rather than that everybody is
|
||||||
|
mailed, and it does not drop an unrelated IDOC warning because a Team preference query timed out.
|
||||||
|
|
||||||
|
##### The projection: what §4.6.1 property 1 actually required
|
||||||
|
|
||||||
|
Property 1 says a new trigger renders through `notify.event` **with no authoring at all**. Nothing
|
||||||
|
implemented it, and building the channel is what made the hole visible: trigger payloads are
|
||||||
|
domain-named (`teamName`, `threadTitle`, `postUrl`) and the generic seeds are structural (`title`,
|
||||||
|
`intro`, `items`, `actionUrl`). The two vocabularies never met.
|
||||||
|
|
||||||
|
`engagement/projection.js` is the meeting, and its rule is one line: **a name the payload already
|
||||||
|
carries is left exactly as emitted; only a name it does not carry is supplied.** `news.post` and
|
||||||
|
`team.announcement` both declare their own `title`, and a projection that overwrote it would replace a
|
||||||
|
real headline with a category label — on the one variable every generic template puts in the subject.
|
||||||
|
|
||||||
|
What it fills comes from the **declaration**, never from a table of domain synonyms:
|
||||||
|
|
||||||
|
| | Filled from | Why not something cleverer |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | the trigger's `label` | a mapping of `threadTitle` → `title` is one game's vocabulary compiled into core, and it is wrong on the first module that names the same thing differently |
|
||||||
|
| `intro` | the trigger's `description` | same |
|
||||||
|
| `actionUrl` | the first declared `url`-typed variable holding a value | it is the only structural fact available: the declaration says which variables are links |
|
||||||
|
| `items` | `[]` | set rather than left absent, so a generic mail does not report `items` as a *missing variable* in the editor's preview |
|
||||||
|
|
||||||
|
The consequence, seen on the live rig and accepted: an unauthored `team.forum.post` mail is titled
|
||||||
|
"Team — new forum post" rather than the thread's title. That is a **plain** mail, not a wrong one, and
|
||||||
|
the operator's answer is the bespoke template shipped beside it. A projection clever enough to do
|
||||||
|
better is a projection that is confidently wrong on the first module that does not follow core's naming.
|
||||||
|
|
||||||
|
##### The digest: the state generalizes, the design does not
|
||||||
|
|
||||||
|
§4.2b's instruction was to keep `teamDigestWorker`'s compute-at-send-time design and generalize its
|
||||||
|
state. Doing that meant **correcting the engine**, not just the worker: Phase 4a's `subscribedTo`
|
||||||
|
enqueued `digest`-mode recipients with a comment reading *"what changes in Phase 6 is who drains it"*,
|
||||||
|
and what changed in Phase 6 is that **nothing drains it**. A digest is re-derived from the source tables
|
||||||
|
when it goes out; an outbox row carries a snapshot taken at emit time, and a snapshot has none of the
|
||||||
|
three properties the design exists for. So the engine now enqueues **`instant` only**, and each of the
|
||||||
|
three properties has its own named test:
|
||||||
|
|
||||||
|
1. a two-day outage sends ONE digest, not two days of replay;
|
||||||
|
2. a post a moderator hid after it was written is not in the query, so it is not in the mail;
|
||||||
|
3. **a user who lost forum access between the post and the send is not mailed** — the security one.
|
||||||
|
|
||||||
|
`engagement_digest_state` replaces `team_notification_prefs.last_digest_at`, keyed
|
||||||
|
`(user_id, channel, scope_key)` so a second digest — on another channel, or over another scope — needs
|
||||||
|
no second column on somebody's preferences row. The backfill is an `INSERT IGNORE … SELECT` in
|
||||||
|
`schema.sql`, and it is replay-safe **by construction rather than by a flag**: the primary key rejects
|
||||||
|
the second run, so a window the new worker has since moved forward is never dragged backwards by a
|
||||||
|
restart. Verified on the rig by restarting the server after a digest had gone out — the stamped row kept
|
||||||
|
its stamp, and the unstamped row was added.
|
||||||
|
|
||||||
|
**The digest is gated on an enabled email rule**, which is the part that was not in the phase body.
|
||||||
|
Without it, disabling the rule would stop the instant mail and leave a daily summary arriving
|
||||||
|
indefinitely, which reads to an operator as the switch being broken.
|
||||||
|
|
||||||
|
##### The unsubscribe: a narrowing, and a path that can never move
|
||||||
|
|
||||||
|
The token is now `(userId, channel, scopeKey)`, signed as `2.<uid>.<channel>.<scope>.<mac>`. **v1
|
||||||
|
tokens still verify, permanently**, and read as `{ channel: 'email', scopeKey: 'team:<id>' }` — which is
|
||||||
|
a reading of what they always meant, since a v1 token could only ever have arrived in an email.
|
||||||
|
|
||||||
|
Two things about it are deliberate and easy to undo by accident:
|
||||||
|
|
||||||
|
- **The route did not move, even though the phase body said the unsubscribe routes would.** The
|
||||||
|
canonical pair is now `/api/v1/public/engagement/unsubscribe/:token`, and
|
||||||
|
`/api/v1/public/teams/unsubscribe/:token` **stays forever**, handing straight to the same handlers.
|
||||||
|
Mail sent before this phase carries the old path in its `List-Unsubscribe` header and in its body;
|
||||||
|
mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. Both paths
|
||||||
|
are in the route manifest.
|
||||||
|
- **A v1 token now turns off email and no longer mutes push.** That is the live behaviour change
|
||||||
|
decision 7 accepted: a link labelled "stop these emails" was quietly stopping notifications on
|
||||||
|
somebody's phone. Verified on the rig — POSTing a v1 token to the old path set `email_mode = 'off'`
|
||||||
|
and left `muted = 0`.
|
||||||
|
|
||||||
|
A channel id may legally contain a dot (`discord.dm` is §3.1's own example) and the token's separator is
|
||||||
|
a dot, so `sign` **refuses** such a channel rather than producing a token that verifies as a different
|
||||||
|
one. A scope the format cannot carry costs the mail its unsubscribe link, not the mail.
|
||||||
|
|
||||||
|
##### Two defects the phase found in code it did not write
|
||||||
|
|
||||||
|
1. **`email.button` never absolutized its href.** `email.image` and `email.itemList` both call
|
||||||
|
`ctx.absolute`; the button called `ctx.safeHref` alone. It had never mattered, because every caller
|
||||||
|
before this phase passed an absolute URL. A trigger's `url` variables are validated **site-relative
|
||||||
|
by construction** (`engagementEmit.RELATIVE_URL` exists so a variable cannot carry a recipient
|
||||||
|
off-site), so every rule-driven call-to-action would have interpolated to `/guilds/x` — a path a mail
|
||||||
|
client has no origin to resolve, i.e. a dead link in every notification the engine sends. Fixed in
|
||||||
|
both parts, with a test either side of the relative/absolute split.
|
||||||
|
2. **The digest's send-log row had no `address_hash`.** The instant path wrote one and the digest path
|
||||||
|
did not, so half the deployment's mail would have been uncorrelatable when Phase 9's bounce handling
|
||||||
|
arrives. Found by reading `engagement_sends` on the live rig, where the two rows sat next to each
|
||||||
|
other. Both paths now hash through the same function, lower-cased and trimmed — a bounce reported for
|
||||||
|
`Darrow@` has to match the row written for `darrow@`.
|
||||||
|
|
||||||
|
##### A naming inconsistency left alone, on purpose
|
||||||
|
|
||||||
|
`team.forum.post` declares its title as `threadTitle` and `team.announcement` declares it as `title`,
|
||||||
|
though both describe a thread in a Team forum. A template can only name one of them, so
|
||||||
|
`notify.team-post`'s `{{threadTitle}}` renders empty for an announcement — which is why the seeded
|
||||||
|
announcement rule points at `notify.event` instead, where the projection gets it right.
|
||||||
|
|
||||||
|
Reconciling the two declarations is a **variable rename, which §4.3 makes a version bump**, and this
|
||||||
|
phase did not take that on its own authority. A test pins the current shape so that reconciling it is a
|
||||||
|
deliberate act rather than a silent rename that empties somebody's subject line.
|
||||||
|
|
||||||
|
##### What an operator sees at cutover, which is the whole of decision 3
|
||||||
|
|
||||||
|
Core seeds four rules — one per Team trigger — all `enabled = 0`, all `audience: 'members'`, all on the
|
||||||
|
email channel. **Team email is off until an operator switches one on.** Three things make that
|
||||||
|
survivable rather than a silent regression:
|
||||||
|
|
||||||
|
- **Admin → Engagement → Rules carries a banner** whenever every Team rule is off, saying that these
|
||||||
|
used to send automatically, that they arrived switched off so nothing starts mailing on its own, and
|
||||||
|
that per-member preferences and unsubscribe links still work above them. It reads the rules rather
|
||||||
|
than a flag, so it disappears the moment one is enabled and returns if they are all switched off
|
||||||
|
again. A deployment that deleted them sees nothing, which is right — they made that choice.
|
||||||
|
- **The release note names it**, alongside the v1-token narrowing.
|
||||||
|
- **The push tickle and the Discord bridge are unaffected.** Only email moved, so the app and the
|
||||||
|
Discord channel keep working exactly as before while the rules are off.
|
||||||
|
|
||||||
|
The rules are seeded **once**, guarded by a settings key rather than ensured on every boot: an operator
|
||||||
|
who deletes a rule must not find it back after a restart, and one they enabled must not be reset to
|
||||||
|
off. The guard is stamped even after a partial run — re-running would duplicate the rules that did
|
||||||
|
insert, and a duplicate rule is two mails per event, which is worse than one missing rule an operator
|
||||||
|
can add from the screen.
|
||||||
|
|
||||||
|
##### What was verified
|
||||||
|
|
||||||
|
- **97 tests**: 35 in `teamNotifyDispatch.test.js` (rewritten — the mail-body assertions moved down to
|
||||||
|
the channel and what is asserted here is now the envelope), 31 in the new `engagementEmail.test.js`,
|
||||||
|
23 in `teamNotify.test.js`, plus the mailer, engine and block-renderer additions. Server suite **1447**
|
||||||
|
green, client **324** green.
|
||||||
|
- **A live rig**, which is where three of the findings above came from: MariaDB + Mailpit + a booted
|
||||||
|
server + a real Team with three members. The walk covered an instant mail with both
|
||||||
|
`List-Unsubscribe` headers, a digest gathering two posts and stamping `engagement_digest_state`, the
|
||||||
|
generic `notify.event` path rendering an announcement with no authoring, a pre-migration v1 token
|
||||||
|
unsubscribing through the old path, and a restart proving the backfill replay-safe.
|
||||||
|
- **The route manifest and swagger** carry the two new routes; the two legacy ones are unchanged.
|
||||||
|
|
||||||
|
**Still later phases':** the push and in-app channels' `deliver` (Phase 7), whether an unverified
|
||||||
|
address may receive opt-in mail (§7.1 Q1's narrower half, Phase 9), and the `address_hash` this phase
|
||||||
|
started writing, which is the column Phase 9's bounce correlation reads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7 — The in-app channel (core + web) ✅
|
||||||
|
|
||||||
`user_notifications`, the in-app `DeliveryChannel`, `GET /auth/me/notifications` + mark-read, and the web
|
`user_notifications`, the in-app `DeliveryChannel`, `GET /auth/me/notifications` + mark-read, and the web
|
||||||
surface (bell + list). Push tickles gain a `ref` that deep-links into the inbox.
|
surface (bell + list). Push tickles gain a `ref` that deep-links into the inbox.
|
||||||
@@ -2365,6 +2619,159 @@ no-op; mark-read is idempotent; a user cannot read another user's row (asserted
|
|||||||
the model); `url` is relative-only, validated by the same character-class rule `pageUrlTemplate` uses.
|
the model); `url` is relative-only, validated by the same character-class rule `pageUrlTemplate` uses.
|
||||||
**Guardrails:** swagger + route manifest; the sanitize path for `body`.
|
**Guardrails:** swagger + route manifest; the sanitize path for `body`.
|
||||||
|
|
||||||
|
#### As built — 7 (2026-08-31)
|
||||||
|
|
||||||
|
The third channel gets behaviour, the oldest one gets a `deliver` at last, and the preferences endpoint
|
||||||
|
Phase 3 shipped with no surface gets one. **Four decisions were settled by the org lead before any
|
||||||
|
code**, two of them widening the phase past its own acceptance line.
|
||||||
|
|
||||||
|
##### Decision 1 — `inapp` defaults to `instant`, and it is the only channel that does
|
||||||
|
|
||||||
|
`coreChannels.js` deferred this in as many words: "whether the inbox is opt-out once it is real is a
|
||||||
|
Phase 7 decision with a live surface to look at." The surface exists now, and the answer is opt-OUT.
|
||||||
|
|
||||||
|
The argument for opt-IN was never about in-app. §7.1 Q1 is standard marketing-email practice and Phase
|
||||||
|
3's `push` default is about a device somebody is holding; **an inbox item wakes nothing and leaves
|
||||||
|
nothing** — it is a row on a page the user chose to open, on this deployment, costing one glance. Left
|
||||||
|
at `off` the channel would ship dead: no rule could reach anybody until every user found a toggle for a
|
||||||
|
channel they had never seen deliver anything. The backlog Phase 3 worried about cannot happen either —
|
||||||
|
the table is empty at cutover, rules default to `enabled = 0`, and every rule carries a per-hour
|
||||||
|
ceiling.
|
||||||
|
|
||||||
|
##### Decision 2 — the phase takes the two pieces its acceptance line omitted
|
||||||
|
|
||||||
|
Two earlier phases assigned work here that Phase 7's own bullets never mention, and both were taken:
|
||||||
|
|
||||||
|
- **`push` gets its `deliver`** (§2603's "the push and in-app channels' `deliver`"). Without it a rule
|
||||||
|
naming push still finished `failed` in the send log — the oldest sink in the system, unreachable from
|
||||||
|
the engine. It is the channel that got behaviour last because until the inbox existed there was
|
||||||
|
nothing for a content-free tickle to point at.
|
||||||
|
- **The web per-channel preferences screen** (Phase 3's as-built: "the screens are Phase 7 (web) and
|
||||||
|
Phase 8 (app)"). The endpoint had shipped with no consumer on either platform.
|
||||||
|
|
||||||
|
##### Decision 3 — the inbox takes `/notifications`; the preferences move under it
|
||||||
|
|
||||||
|
`/auth/me/notifications/*` was already the preferences namespace — `streams`, `subscriptions`,
|
||||||
|
`channels`, `teams` — and `/account/notifications` was already the preferences *page*, with a bell icon
|
||||||
|
in the portal nav. Content and settings are different kinds of thing, and **the plain word belongs to
|
||||||
|
the content**: it is what a person means when they say "notifications", and what the bell opens.
|
||||||
|
|
||||||
|
So the inbox is `GET /auth/me/notifications` and the page is `/account/notifications`; the preferences
|
||||||
|
screen moved to `/account/notifications/settings` and gained its own nav row. Route order is not
|
||||||
|
incidental and is commented as such: the four named preference sub-paths are declared above, and the one
|
||||||
|
parameterised path added below them is a **POST** whose `:id` is digits-only, so nothing can shadow
|
||||||
|
`streams` or `channels`.
|
||||||
|
|
||||||
|
##### Decision 4 — `ctx.inbox.push` respects a preference where one exists
|
||||||
|
|
||||||
|
The rule-less sink has no trigger declaration to project from, no rule to pick a template and no
|
||||||
|
audience to resolve. It now writes the inbox directly **unless** `triggerId` names a *registered*
|
||||||
|
trigger and that user's effective `inapp` mode is not `instant`: a toggle somebody switched off must not
|
||||||
|
be walkable around by the module that owns the trigger behind it. An id nothing has registered has no
|
||||||
|
toggle on any screen, so there is no preference to protect and the item is written.
|
||||||
|
|
||||||
|
Scoped preferences are deliberately not consulted — a scope is a property of an *event* (`team:12`), and
|
||||||
|
a caller with no declaration has no scope to name. The engine's path, which does, still applies them.
|
||||||
|
|
||||||
|
##### The block → column mapping, which is the whole of how a template becomes a row
|
||||||
|
|
||||||
|
`user_notifications` has `title` / `body` / `url` where email has a subject and a document. The in-app
|
||||||
|
renderer (`templates.renderInappByKey`) maps by block **role**: the first `email.heading` is the title,
|
||||||
|
the first `email.button` is the url, everything else is the body. A second heading or button is ordinary
|
||||||
|
body content, which is what an operator who added one meant.
|
||||||
|
|
||||||
|
**The body is TEXT, not the email HTML**, and that is load-bearing 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
|
||||||
|
every block already promises. The consequence worth stating: **there is no operator markup on this
|
||||||
|
surface to sanitize, and no way for one to appear.** The phase's "sanitize path for `body`" guardrail is
|
||||||
|
discharged by the column never holding markup in the first place, which is a stronger guarantee than a
|
||||||
|
sanitizer.
|
||||||
|
|
||||||
|
##### Five things the tree contradicted, or the build found
|
||||||
|
|
||||||
|
- **The shipped `inapp.event` seed named variables nothing supplies.** Phase 5a wrote it before the
|
||||||
|
channel that renders it existed, declaring `body` and `url` — but a trigger declares domain names
|
||||||
|
(`teamName`, `threadTitle`) and `projection.project` fills the gaps with the *structural* ones
|
||||||
|
(`title`, `intro`, `actionUrl`). Every rendering would have produced a title and nothing else.
|
||||||
|
Renamed to `notify.event`'s vocabulary at **`seedVersion` 2**, which is §4.6.1 property 1 restated for
|
||||||
|
this channel: a new trigger must render with no authoring at all.
|
||||||
|
- **The dedupe index is scoped to the USER, which is narrower than the outbox's.** `engagement_outbox`
|
||||||
|
scopes 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**. Same family of
|
||||||
|
defect as the global index Phase 4a found in §4.2a, in the opposite direction.
|
||||||
|
- **The push tickle's `ref` needed an ordering to be worth anything.** A rule spanning `inapp` and
|
||||||
|
`push` enqueues two independent rows and the outbox sweeps `ORDER BY due_at, id`, so the ref only
|
||||||
|
resolves if the in-app row was enqueued first. `engine.liveChannels` now sorts `inapp` ahead of the
|
||||||
|
rest (`CHANNEL_ORDER`) — an ordering, not a dependency: the ref is a **hint**, null when there is no
|
||||||
|
row, and the app's contract stays wake-and-pull.
|
||||||
|
- **There was no retention policy for this table at all**, and neither the outbox nor the send log
|
||||||
|
bounds it (both hold one row per *delivery*; an inbox item outlives its delivery by design).
|
||||||
|
`utils/userNotificationsPrune.js` is `teamActivityPrune`'s shape with one policy difference:
|
||||||
|
**read items only.** Age alone would delete the evidence for "I was never told", which is the
|
||||||
|
complaint this table answers. The horizon is `settings.user_notifications_retain_days`, default 90.
|
||||||
|
- **Staff had no reachable inbox, and only the live rig could see it.** `/auth/me/notifications` is
|
||||||
|
role-agnostic — behind `requireAuth` only, like every `/auth/me` route — so the server, the tests
|
||||||
|
and the API all agreed a staff member had an inbox. On the web they did not: `RequirePlayer` sends
|
||||||
|
anyone who is not a player out of `/account` (staff manage their own account under `/admin/account`),
|
||||||
|
so the bell pointed at a page that redirects. **Signed in as an admin, the feature was unreachable.**
|
||||||
|
Fixed by mounting the same two components at `/admin/notifications` and
|
||||||
|
`/admin/notifications/settings`, adding the bell to the admin header, and putting the one mapping in
|
||||||
|
`client/src/lib/notificationPaths.js` with its own test. One trap inside the fix worth keeping:
|
||||||
|
`allowedPathsFor` turns an `end: true` nav row into an EXACT match, so marking the admin row exact
|
||||||
|
left `/admin/notifications/settings` outside the allowlist and bounced staff off their own
|
||||||
|
preferences screen — the row has to cover its sub-routes.
|
||||||
|
|
||||||
|
##### What an operator and a user actually see
|
||||||
|
|
||||||
|
- **The bell** sits in the public site header and in the player portal's own header, renders nothing
|
||||||
|
when signed out, and **polls** its badge once a minute — pausing while the tab is hidden and
|
||||||
|
refreshing the moment it comes back. There is nothing to push over: the site's two SSE streams are
|
||||||
|
the shard's, neither is per-user, and a third authenticated stream carrying one integer would mean an
|
||||||
|
open connection per signed-in tab forever.
|
||||||
|
- **The preferences screen is now a matrix**, not a checkbox list. The push-only stream list it replaced
|
||||||
|
was a strict subset: `/notifications/channels` already returns every push stream *and* every event
|
||||||
|
trigger with the effective mode on each channel that applies, so a trigger-only id simply has no push
|
||||||
|
cell and core never has to explain which kind of id a row is. The two legacy whole-set endpoints are
|
||||||
|
untouched and are that surface's push projection, so **the shipped Android app keeps its wire shape**.
|
||||||
|
|
||||||
|
##### What was verified
|
||||||
|
|
||||||
|
- **28 new tests**: 23 in `engagementInapp.test.js` (the five acceptance criteria, the role mapping, the
|
||||||
|
four `ctx.inbox.push` cases, the tickle's exact key set, and the route-level ownership check) and 5 in
|
||||||
|
`userNotificationsSql.test.js` — a throwaway MariaDB, because three properties here are a *server*
|
||||||
|
contract rather than a reading of this code: a UNIQUE index admitting many NULLs, `INSERT IGNORE`
|
||||||
|
reporting `affectedRows = 0` on a duplicate, and `read_at IS NULL` making mark-read idempotent.
|
||||||
|
- **Two existing tests moved with the behaviour, and both moves are the point.**
|
||||||
|
`engagementEngine`'s "a channel with no `deliver()` finishes failed" named `inapp` (and `email` before
|
||||||
|
it) and so was rewritten by every phase that gave a channel behaviour; it now registers a throwaway
|
||||||
|
channel, because the property was never about a particular one. `notificationChannelPrefs`'s defaults
|
||||||
|
assertion carries decision 1.
|
||||||
|
- **Swagger and the route manifest** carry the four new routes, with four new component schemas.
|
||||||
|
- **A live rig**: MariaDB + a booted server + the real outbox worker + a browser. The walk is where
|
||||||
|
the staff-reachability defect came from, and it also proved the three things unit tests cannot —
|
||||||
|
that `liveChannels`' ordering really does put the in-app row first (a rule stored as
|
||||||
|
`["push","inapp"]` enqueued outbox 2 = inapp before outbox 3 = push, and the tickle carried
|
||||||
|
`ref: "notification:2"`); that **two rules on one event produce three outbox rows and exactly ONE
|
||||||
|
inbox item**, with the send log saying "already in this inbox (duplicate dedupe key)" rather than
|
||||||
|
claiming a second delivery; and that the retention worker drops an aged READ row while leaving an
|
||||||
|
equally aged UNREAD one. The preferences matrix wrote exactly one row for the one cell that changed.
|
||||||
|
|
||||||
|
**One thing this phase did NOT wire, and it is worth knowing before Phase 11.** `news.post` is a
|
||||||
|
declared trigger that **nothing emits through the engine** — `coreTriggers.js` says so in as many words
|
||||||
|
("these declare; nothing here emits yet") and Phase 6 migrated only the four `team.*` ones, so the
|
||||||
|
admin publish path still fires a raw `pushDispatch.publish` beside the engine rather than through it.
|
||||||
|
The consequence for this phase: on a real deployment the only in-app items a rule can produce today
|
||||||
|
come from the four Team triggers. Wiring the news emitter is a one-line `ctx.events.emit`-shaped change
|
||||||
|
that belongs with whoever owns that decision, not smuggled into the channel's own phase. **Written up as
|
||||||
|
§7.1 Q9**, which sets out the three other things a publish already fires (an announce leg, a module's
|
||||||
|
post hook, the raw tickle), which of them the engine replaces and which it must not touch, and the
|
||||||
|
continuity question that has to be answered before anyone writes the line. Recommended home: Phase 11.
|
||||||
|
|
||||||
|
**Still later phases':** the app's inbox screen and the tickle → pull → inbox path (Phase 8), and the
|
||||||
|
suppression list, which this channel has no equivalent of — there is no address to suppress.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 8 — The in-app channel (Android)
|
### Phase 8 — The in-app channel (Android)
|
||||||
@@ -2423,10 +2830,19 @@ starts early and lands independently.
|
|||||||
with Phase 6 if Phase 10 is still in flight — the trigger simply omits `nextStage`/`estimatedCollapse`
|
with Phase 6 if Phase 10 is still in flight — the trigger simply omits `nextStage`/`estimatedCollapse`
|
||||||
until the v5 overlay is deployed, which the `required: false` declaration already permits.
|
until the v5 overlay is deployed, which the `required: false` declaration already permits.
|
||||||
|
|
||||||
|
**Also core's own `news.post` emitter, which this phase's title understates** (§7.1 Q9). `news.post` is
|
||||||
|
declared with no caller, so a rule naming it can never fire; wiring it is one `ctx.events.emit`-shaped
|
||||||
|
call in `announceIfNewlyPublished`, gated on the same job-id transition signal the push already uses.
|
||||||
|
**The announce legs and the post hooks are untouched** — a module's town-crier leg is a delivery to a
|
||||||
|
channel of the deployment and its news-gump hook is state mirroring, neither of which is a per-person
|
||||||
|
notification. What the emit replaces is the raw `pushDispatch.publish` beside them, and Q9's continuity
|
||||||
|
question has to be answered before it does.
|
||||||
|
|
||||||
**Acceptance:** the five-rung shard visibility walk still shows no leak; a house transitioning to
|
**Acceptance:** the five-rung shard visibility walk still shows no leak; a house transitioning to
|
||||||
`Greatly` on the live rig produces one email to the linked owner and nothing to anyone else; a second
|
`Greatly` on the live rig produces one email to the linked owner and nothing to anyone else; a second
|
||||||
transition inside the cooldown produces nothing; a refresh back to `LikeNew` inside the delay window
|
transition inside the cooldown produces nothing; a refresh back to `LikeNew` inside the delay window
|
||||||
cancels the pending mail.
|
cancels the pending mail; **a news post published on the rig reaches a rule, and the town-crier leg and
|
||||||
|
every registered post hook still fire exactly as they did.**
|
||||||
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point.
|
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -2559,7 +2975,7 @@ day it ships.
|
|||||||
|
|
||||||
## Part 7 — Open questions and forward-compat notes
|
## Part 7 — Open questions and forward-compat notes
|
||||||
|
|
||||||
### 7.1 Questions for the org lead — seven answered, one still open
|
### 7.1 Questions for the org lead — seven answered, two still open
|
||||||
|
|
||||||
1. ✅ **ANSWERED — may unverified addresses receive engagement mail?** *"Emails need to be unique and
|
1. ✅ **ANSWERED — may unverified addresses receive engagement mail?** *"Emails need to be unique and
|
||||||
verification blocking sending is an admin setting."* Combined with the opt-in answer, this settles
|
verification blocking sending is an admin setting."* Combined with the opt-in answer, this settles
|
||||||
@@ -2622,6 +3038,52 @@ day it ships.
|
|||||||
nine M12 phase PRs. Fix the trigger as Phase 8's first commit, or accept it deliberately?
|
nine M12 phase PRs. Fix the trigger as Phase 8's first commit, or accept it deliberately?
|
||||||
Recommendation: fix it. It is a two-line workflow change and the alternative is finding out about a
|
Recommendation: fix it. It is a two-line workflow change and the alternative is finding out about a
|
||||||
Kotlin compile error during the cutover window.
|
Kotlin compile error during the cutover window.
|
||||||
|
9. **Core's own `news.post` emitter — and the three other things a publish already fires.**
|
||||||
|
`config/coreTriggers.js` declares core's five triggers and says in as many words that **nothing here
|
||||||
|
emits yet**; Phase 6 migrated only the four `team.*` ones onto the engine. So `news.post` is a
|
||||||
|
declared payload contract with **no caller**, and on a real deployment the only in-app or email items
|
||||||
|
a rule can produce today come from Teams. Phase 7 found this and deliberately did not wire it, on the
|
||||||
|
grounds that a channel's own phase is not the place to give another phase's trigger an emitter.
|
||||||
|
|
||||||
|
**The call itself is one line. The care is entirely in what it must not disturb.**
|
||||||
|
`admin.controller.js`'s `announceIfNewlyPublished` already fans one publish four ways, and they are
|
||||||
|
different in kind — three of them are *not* the engagement engine's business:
|
||||||
|
|
||||||
|
| What fires on a publish | Whose | What kind of thing it is | The engine's? |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `announceJobs.enqueueIfNeeded` → `announce_job_legs` | core, with legs registered by modules — `module-uo` owns `towncrier` | a one-shot **delivery to a channel of the deployment**, with retry and classification | **No** |
|
||||||
|
| `registries.dispatchPostHook('onSaved')` | modules, via `registerPostHook` (API 1.1.0) | idempotent **state mirroring** — it also runs on delete, and refreshes silently on an edit | **No** |
|
||||||
|
| `pushDispatch.publish('news.post', { ref })` | core | a **per-person notification**, to whoever subscribed | **Yes — this is the one that should become an emit** |
|
||||||
|
| *(missing)* `engagementEmit.emit('core', 'news.post', …)` | core | rules → email / in-app / push | **Yes** |
|
||||||
|
|
||||||
|
`registries.js` already states the first two apart and why they were not folded together ("a leg is a
|
||||||
|
one-shot DELIVERY with retry and classification; a post hook maintains idempotent STATE, has to run on
|
||||||
|
delete as well as save, and refreshes silently on an edit"). **Adding the engine makes a third
|
||||||
|
distinction of the same kind, not a replacement for either.** A module's town-crier leg and a module's
|
||||||
|
news gump must keep firing exactly as they do; what changes is only that the raw tickle stops being
|
||||||
|
the one person-facing sink and becomes one channel of a rule.
|
||||||
|
|
||||||
|
**What a module gets out of this, stated so nobody widens it by accident.** A module already has two
|
||||||
|
doors onto a news publish — the announce leg and the post hook — and it keeps both. What it does *not*
|
||||||
|
get is the ability to fire `news.post` itself: the id's owner is core, `ctx.events.emit` binds the
|
||||||
|
owner at the call and never reads it from the arguments, and §7.2's one namespace means an id has
|
||||||
|
exactly one owner across both facets. A module that wants a person-facing notification of its own
|
||||||
|
declares its own trigger through `registerEventTriggers`. That is the whole of "modules can use it".
|
||||||
|
|
||||||
|
**Three things to settle before anyone writes the line:**
|
||||||
|
- **Continuity, and it is the same shape as G22 and Phase 6's decision 3.** Today publishing news
|
||||||
|
tickles every `news.post` subscriber directly. If the emit *replaces* that call, push stops the
|
||||||
|
moment this lands and stays stopped until an operator enables a rule — silently, because `enabled`
|
||||||
|
defaults to `0`. Either core seeds a `news.post` rule (and then: enabled, against the standing
|
||||||
|
default, or disabled with a banner as the Team rules got?), or the raw tickle stays beside the emit
|
||||||
|
for one release and is removed once a rule is known to exist.
|
||||||
|
- **The transition signal must be reused, not re-derived.** `enqueueIfNeeded` returning a truthy job
|
||||||
|
id is the single "newly published news" test, and the push call already piggybacks on it
|
||||||
|
deliberately so an edit or a re-publish does not re-fire. The emit must gate on the same value; a
|
||||||
|
second reading of the transition is a second chance to disagree with the first.
|
||||||
|
- **Which phase owns it.** Recommendation: **Phase 11**, which already ships "the first real rule" and
|
||||||
|
is where a declared trigger first gets a caller. It is core work rather than `module-uo`'s, so that
|
||||||
|
phase's title understates it — say so there rather than inventing a phase for one call site.
|
||||||
|
|
||||||
### 7.2 One namespace, or two?
|
### 7.2 One namespace, or two?
|
||||||
|
|
||||||
|
|||||||
158
website/TEAMS.md
158
website/TEAMS.md
@@ -1595,8 +1595,13 @@ function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient
|
|||||||
would be a refactor away from being used.
|
would be a refactor away from being used.
|
||||||
|
|
||||||
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
|
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
|
||||||
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
|
a `last_digest_at DATETIME NULL`, and it is surfaced in two places:
|
||||||
places:
|
|
||||||
|
> **`last_digest_at` is no longer read** (engagement Phase 6). The digest's state moved to
|
||||||
|
> `engagement_digest_state`, keyed `(user_id, channel, scope_key)` so a second digest needs no second
|
||||||
|
> column here; `schema.sql` backfills it once. The column stays as the backfill's source and as the
|
||||||
|
> record of what a row meant before the migration. **The two preference columns are unchanged and are
|
||||||
|
> still the authority for Team notifications** — the engine reads them rather than replacing them.
|
||||||
|
|
||||||
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
|
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
|
||||||
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
|
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
|
||||||
@@ -1608,7 +1613,7 @@ places:
|
|||||||
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
|
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
|
||||||
"is this person in it", and the guild page is public.
|
"is this person in it", and the guild page is public.
|
||||||
|
|
||||||
### 6.4 Email — the third sink, already built and unused
|
### 6.4 Email — the third sink
|
||||||
|
|
||||||
Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment
|
Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment
|
||||||
running neither currently gets no notification that someone replied to their own thread** — which is
|
running neither currently gets no notification that someone replied to their own thread** — which is
|
||||||
@@ -1618,47 +1623,120 @@ Core already has `utils/mailer.js` and an admin-configured `email_config`. The e
|
|||||||
notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the
|
notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the
|
||||||
same event**, not a fourth pipeline.
|
same event**, not a fourth pipeline.
|
||||||
|
|
||||||
- Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is off.
|
> **Rewritten 2026-08-29 — the engagement system's Phase 6 took this sink over.** Everything below the
|
||||||
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
|
> line still describes what a recipient receives; what changed is who decides to send it. The design of
|
||||||
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
|
> record for the mechanism is now `docs/website/ENGAGEMENT.md` (Phase 6 as built), and this section is
|
||||||
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
|
> the Teams-shaped view of it. **Do not re-specify the engine here** — the same rule §6.0b applies to
|
||||||
|
> every other doc that touches a contract it does not own.
|
||||||
|
|
||||||
|
#### What moved, and what did not
|
||||||
|
|
||||||
|
`teamNotify.js` had three sinks. **One moved:**
|
||||||
|
|
||||||
|
| Sink | Where it lives now |
|
||||||
|
| --- | --- |
|
||||||
|
| The content-free push tickle | still `teamNotify.js`, unchanged. Its `deliver` on the engine is the engagement Phase 7's, with the in-app inbox that gives a tickle a `ref` worth deep-linking |
|
||||||
|
| The Discord bridge (§7.2) | still `teamNotify.js`, unchanged. A bridge is a *leg* — one-shot, to whoever can read a channel — and not a per-recipient *channel*; `ENGAGEMENT.md` §3.1 argues that distinction and it holds here |
|
||||||
|
| **Email** | **the engagement engine.** `teamNotify.forumPost` emits `team.forum.post` / `team.announcement`; a rule decides who is mailed, through which template, how often at most |
|
||||||
|
|
||||||
|
`mailer.sendTeamNotification` and `teamNotify.emailImmediate` no longer exist. The mail body is an
|
||||||
|
`engagement_templates` row an operator can edit (`notify.team-post` for a post, `notify.digest` for the
|
||||||
|
digest, `notify.event` for the two roster events and for announcements).
|
||||||
|
|
||||||
|
#### The four properties this section always claimed, and where each one lives now
|
||||||
|
|
||||||
|
- **Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is
|
||||||
|
off.** All three still hold, and the first is now explicit rather than incidental:
|
||||||
|
`teamNotify.recipientIds` computes the access-checked set and it travels on the event envelope as
|
||||||
|
`recipientUserIds`. A rule whose audience is `members` resolves to exactly that set — still filtered
|
||||||
|
for `users.status = 'active'`, still under the trigger's ceiling. Core does not learn what a Team is;
|
||||||
|
the event says who it is about.
|
||||||
|
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge (§7.2):
|
||||||
|
the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable
|
||||||
|
topic. It carries the thread title, an excerpt and a link; never the full post.
|
||||||
|
- **The per-Team preference is unchanged and is still the authority.** `team_notification_prefs` stays
|
||||||
|
exactly where it is, with exactly the meaning §6.3 gives it. The engine reads it through a
|
||||||
|
**scoped-preference** adapter: for a Team-scoped event that table *is* the preference, `muted`
|
||||||
|
silences every channel, and `email_mode` decides email and says nothing about the others. The
|
||||||
|
alternative — intersecting it with the newer per-stream preference — would have silenced every
|
||||||
|
existing subscriber on the migrating deploy, because nobody has ever expressed a stream-level opinion
|
||||||
|
about a Team trigger. The argument in full is in ENGAGEMENT.md Phase 6.
|
||||||
|
- **Off unless email is configured.** No usable `email_config` means the sink is absent, not broken —
|
||||||
|
and as of Phase 6 there is a second gate above it, below.
|
||||||
|
|
||||||
|
#### **Team email is OFF until an operator turns it on**
|
||||||
|
|
||||||
|
This is the one live behaviour change and it is deliberate. An engagement rule arrives `enabled = 0` so
|
||||||
|
that no import, restore or upgrade can start mailing on its own, and core seeds four Team rules under
|
||||||
|
that same rule. **On upgrade, Team notification emails stop until somebody opens Admin → Engagement →
|
||||||
|
Rules and switches one on.** The screen carries a banner saying so for as long as every Team rule is
|
||||||
|
off; the release note says it too. Push and the Discord bridge are unaffected.
|
||||||
|
|
||||||
|
#### The digest
|
||||||
|
|
||||||
|
**Computes at send time and keeps no queue.** The worker asks what arrived after the last stamp and
|
||||||
|
re-runs the access resolver. Three properties fall out, and the third is why it was chosen over a
|
||||||
|
pending-items table — and, in Phase 6, over the engine's own outbox:
|
||||||
|
|
||||||
|
1. a deployment down for two days sends **one** correct digest rather than replaying a backlog;
|
||||||
|
2. a post a moderator hid after it was written is simply not in the query;
|
||||||
|
3. **a user who lost forum access between the post and the send is no longer in the recipient set**, so
|
||||||
|
they are not emailed content they can no longer read.
|
||||||
|
|
||||||
|
`since` is clamped to at most seven days so a long outage cannot produce one enormous mail, and the
|
||||||
|
stamp is written **only on a successful send** — stamping first would quietly eat a day of somebody's
|
||||||
|
notifications every time the mail provider had a bad minute.
|
||||||
|
|
||||||
|
**What Phase 6 changed is the state, not the design.** The stamp moved from
|
||||||
|
`team_notification_prefs.last_digest_at` into `engagement_digest_state`, keyed
|
||||||
|
`(user_id, channel, scope_key)`, backfilled once by `schema.sql`. A digest-mode recipient gets **no
|
||||||
|
outbox row** — a row would carry a snapshot taken at publish time and would have none of the three
|
||||||
|
properties above. The worker is also gated on an enabled email rule, so switching Team email off
|
||||||
|
switches off both halves of it rather than the instant half only.
|
||||||
|
|
||||||
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
|
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
|
||||||
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
|
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
|
||||||
`team_notification_prefs`.
|
`team_notification_prefs`.
|
||||||
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
|
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
|
||||||
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
|
> would start mailing every member of every Team the moment an operator connects a mail transport.
|
||||||
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
|
> Email is the one opt-IN sink here. Push stays opt-out, because a mute silences something the user
|
||||||
> has.
|
> already has.
|
||||||
- **The digest computes at send time and keeps no queue** (as built). The only state is
|
- **Roster events do not email by default** (as built, restated by Phase 6). `team.member.joined` and
|
||||||
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
|
`team.leadership.changed` do now *emit*, so an operator who wants that mail can have it — but the
|
||||||
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
|
rules that would send it are seeded disabled and carry an hour-long cooldown, so §6.4's original
|
||||||
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
|
argument survives as the default rather than as a sink the code declines to call.
|
||||||
hid after it was written is simply not in the query; and **a user who lost forum access between the
|
|
||||||
post and the send is no longer in the recipient set**, so they are not emailed content they can no
|
#### One-click unsubscribe
|
||||||
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
|
|
||||||
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
|
A link honouring the same per-Team preference, so an unsubscribe from the mail client writes what the
|
||||||
eat a day of somebody's notifications every time the mail provider had a bad minute.
|
site shows.
|
||||||
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
|
|
||||||
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
|
> **A stateless HMAC, not a token table.** Every property that makes a password-reset token a row is
|
||||||
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
|
> absent here — the link sits in a mailbox for months so it has no useful expiry, and clicking it twice
|
||||||
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
|
> must mean what clicking it once meant. The capability is deliberately the narrowest that does the
|
||||||
writes the preference the site shows.
|
> job: turn **one channel** off for **one scope** for one account. It reads nothing, cannot turn
|
||||||
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
|
> anything back on, and names no other scope. `version` is the only revocation a stateless design can
|
||||||
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
|
> offer — retiring one invalidates every outstanding link of it at once — and it exists before it is
|
||||||
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
|
> needed rather than after.
|
||||||
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
|
>
|
||||||
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
|
> **Phase 6 generalized the token from `(userId, teamId)` to `(userId, channel, scopeKey)`, and
|
||||||
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
|
> narrowed what it does.** A v1 token set `muted`, which silenced that Team's *push* as well as its
|
||||||
> once — and it exists before it is needed rather than after.
|
> email — a link labelled "stop these emails" quietly stopping notifications on somebody's phone. A
|
||||||
>
|
> token now turns off the channel it names and nothing else. **Old tokens still verify, permanently**,
|
||||||
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
|
> and read as the email channel for that Team, which is a reading of what they always meant.
|
||||||
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
|
>
|
||||||
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
|
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
|
||||||
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
|
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
|
||||||
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
|
> `List-Unsubscribe` *header* carries `POST /api/v1/public/engagement/unsubscribe/:token`, because RFC
|
||||||
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
|
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and does
|
||||||
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
|
> not act** — a mail client's link scanner would otherwise silently unsubscribe people who asked for
|
||||||
> session behind it.
|
> nothing. The endpoint answers `200` whatever the token was: a response that distinguished a valid
|
||||||
|
> token from a forgery would be an oracle for which (user, scope) pairs exist, on a surface with no
|
||||||
|
> session behind it.
|
||||||
|
>
|
||||||
|
> **`POST|GET /api/v1/public/teams/unsubscribe/:token` still exists and always will.** It hands
|
||||||
|
> straight to the same handlers. Mail sent before Phase 6 carries that path in its header and in its
|
||||||
|
> body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe.
|
||||||
|
|
||||||
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
|
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
|
||||||
is already being built there.
|
is already being built there.
|
||||||
|
|||||||
Reference in New Issue
Block a user