Compare commits
15 Commits
6c19a0608a
...
docs/engag
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fd5d065b7 | |||
| d9abe3d941 | |||
| 73c3a467e7 | |||
| c93151b352 | |||
| 2161119c8c | |||
| 556124562b | |||
| c03dfc14ba | |||
| aa5c1d63b0 | |||
| 315a7c7fca | |||
| 73e1669a5c | |||
| 97b652fed3 | |||
| 5924276fe7 | |||
| deaf491dc5 | |||
| 713e6fa6c8 | |||
| 7b7a26f0ac |
@@ -1250,6 +1250,67 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes
|
|||||||
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
|
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
|
||||||
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.
|
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.
|
||||||
|
|
||||||
|
### Per-channel preferences — the superset endpoint (engagement phase 3, 2026-08-29)
|
||||||
|
|
||||||
|
Push is no longer the only channel a preference can name. `docs/website/ENGAGEMENT.md` phase 3 added
|
||||||
|
`notification_channel_prefs` and, with it, `GET · PUT /auth/me/notifications/channels`.
|
||||||
|
|
||||||
|
**Nothing above changed.** `/notifications/streams` and `/notifications/subscriptions` keep their
|
||||||
|
exact wire shapes, including the `{"streams":[]}` gotcha, and the shipped APK needs no update to keep
|
||||||
|
working — `notification_subscriptions` is now the **push projection** of the new table, and every
|
||||||
|
write to either fans out to the other. That was the acceptance criterion the phase was built against,
|
||||||
|
with the empty-array case tested explicitly.
|
||||||
|
|
||||||
|
**What the new endpoint adds, for whenever the app adopts it:**
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// GET /auth/me/notifications/channels
|
||||||
|
{
|
||||||
|
"channels": [ // the delivery-channel registry
|
||||||
|
{ "id": "push", "label": "Push", "carriesContent": false,
|
||||||
|
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] },
|
||||||
|
{ "id": "email", "label": "Email", "carriesContent": true,
|
||||||
|
"defaultMode": "off", "supportsDigest": true, "modes": ["off", "instant", "digest"] },
|
||||||
|
{ "id": "inapp", "label": "On the site", "carriesContent": true,
|
||||||
|
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] }
|
||||||
|
],
|
||||||
|
"items": [ // every subscribable id, streams AND triggers
|
||||||
|
{ "id": "news.post", "label": "News posts", "description": "…",
|
||||||
|
"personal": false, "requiresLinkedAccount": false, "ceiling": "authenticated",
|
||||||
|
"channels": ["push", "email", "inapp"],
|
||||||
|
"modes": { "push": "instant", "email": "off", "inapp": "off" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Four properties the UI should be built on rather than around:
|
||||||
|
|
||||||
|
- **`items` is the union of streams and triggers**, one namespace. An id can be a push stream, an
|
||||||
|
event trigger with a payload contract, or both. A trigger-only id (`uo.house.idoc_warning`) carries
|
||||||
|
no `push` in its `channels` and no `push` key in `modes` — there is nothing registered to push it —
|
||||||
|
so **render the toggles from `channels`, never from a hardcoded three**.
|
||||||
|
- **`modes` is the *effective* mode, not the stored one.** Where the user has expressed nothing, the
|
||||||
|
server substitutes that channel's `defaultMode`. The client never has to know which it is looking
|
||||||
|
at, and must not re-implement the defaulting.
|
||||||
|
- **The PUT is sparse, and this is the one place it diverges from every other `/auth/me` PUT.** Send
|
||||||
|
only the pairs you changed: `{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}`.
|
||||||
|
Everything not named is left alone, so the notifications screen can save one toggle without holding
|
||||||
|
the whole table. `off` is a mode, never an omission — **so the empty-array gotcha above does not
|
||||||
|
apply here at all**: there is no "clearing the last one" case, because turning something off is a
|
||||||
|
row like any other. `prefs` is still required, so a DTO field with no default is still the right
|
||||||
|
shape.
|
||||||
|
- **Entries the server cannot accept are dropped, not refused** — an unknown id, a channel that does
|
||||||
|
not apply to that id, a `digest` on a channel that cannot batch. The response is the full stored
|
||||||
|
state, so re-render from it rather than assuming the request took.
|
||||||
|
|
||||||
|
**One id may be missing from `items` that the app expects.** A trigger whose declared audience
|
||||||
|
`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it
|
||||||
|
would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged.
|
||||||
|
|
||||||
|
**Phase 8** (`ENGAGEMENT.md`) is where the app grows the in-app inbox and this screen gains the
|
||||||
|
per-channel toggles. Until then the existing per-stream screen keeps working against
|
||||||
|
`/notifications/subscriptions` unmodified.
|
||||||
|
|
||||||
## 12. Build & CI (Gitea Actions)
|
## 12. Build & CI (Gitea Actions)
|
||||||
|
|
||||||
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
|
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other
|
||||||
|
|||||||
@@ -180,13 +180,19 @@ server/
|
|||||||
from a manifest URL, enable,
|
from a manifest URL, enable,
|
||||||
disable, uninstall, purge, restart
|
disable, uninstall, purge, restart
|
||||||
and the source allowlist
|
and the source allowlist
|
||||||
engagement.router.js (2) /admin/engagement — adminOnly,
|
engagement.router.js (22) /admin/engagement — adminOnly,
|
||||||
the declared event catalog. Read
|
the declared event catalog (three
|
||||||
only and table-free: it serves the
|
table-free reads, served from the
|
||||||
module registries. Rules, templates
|
module registries) plus the rules
|
||||||
and the send log land under this
|
and audience segments an operator
|
||||||
same prefix in ENGAGEMENT.md
|
configures over it, the count-only
|
||||||
Phases 4 and 5
|
reach preview, the message
|
||||||
|
templates and their sandboxed
|
||||||
|
preview / test send, and the send
|
||||||
|
log (G15). Two of these are POSTs
|
||||||
|
that write nothing: preview and
|
||||||
|
test-send act on the draft in the
|
||||||
|
request, not the stored row
|
||||||
email.router.js (4) /admin/email — outbound mail:
|
email.router.js (4) /admin/email — outbound mail:
|
||||||
transport + credentials + send
|
transport + credentials + send
|
||||||
test — adminOnly. The two
|
test — adminOnly. The two
|
||||||
@@ -461,6 +467,298 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
|
|||||||
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
|
||||||
replaces the whole set. Nothing is pushed unless the user subscribed.
|
replaces the whole set. Nothing is pushed unless the user subscribed.
|
||||||
|
|
||||||
|
Engagement phase 3 made this the **push projection** of `notification_channel_prefs` below. It keeps
|
||||||
|
its exact shape and stays what `utils/pushDispatch` reads — the shipped Android client cannot be
|
||||||
|
changed from this side — and the general table carries the channel dimension it lacks.
|
||||||
|
|
||||||
|
### notification_channel_prefs — which channel, in which mode (engagement phase 3)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
|
| stream_id | VARCHAR(64) NOT NULL | a stream id **or** a trigger id — **one namespace** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.2), which is what keeps this key single-column |
|
||||||
|
| channel | VARCHAR(32) NOT NULL | `email` / `push` / `inapp`, from the delivery-channel registry (`src/engagement/channels.js`) |
|
||||||
|
| mode | ENUM('off','instant','digest') NOT NULL DEFAULT 'off' | `digest` only where the channel declares `supportsDigest` |
|
||||||
|
| updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`PRIMARY KEY(user_id, stream_id, channel)`, `INDEX(channel, mode)`.
|
||||||
|
|
||||||
|
**A row exists only where the user has expressed something, and absence is the *channel's* default,
|
||||||
|
not `off`.** That default lives in the channel registry and nowhere else (§3.1, G9: push, email and
|
||||||
|
in-app do not agree on it). All three currently declare `off`, so absence and off happen to coincide
|
||||||
|
today — a fact about the declarations, not about this table, and code must not assume it. The column
|
||||||
|
`DEFAULT` is the value a write with no mode takes, not the meaning of a missing row.
|
||||||
|
|
||||||
|
**It is a superset of `notification_subscriptions`, which becomes its push projection.** The shipped
|
||||||
|
Android client's wire shape is frozen (`{streams:[…]}`), so the old table stays exactly what
|
||||||
|
`utils/pushDispatch` reads and every write to either fans out to the other. The invariant both
|
||||||
|
directions maintain: **a `push` row with `mode <> 'off'` ⟺ a `notification_subscriptions` row.** An
|
||||||
|
explicit `off` is *stored* rather than deleted — folding "I turned this off" back into "I never said"
|
||||||
|
is only harmless while the default is off. Existing subscriptions are carried across by an
|
||||||
|
`INSERT IGNORE … SELECT` backfill in `schema.sql`, replay-safe on every boot like the
|
||||||
|
`announce_jobs → announce_job_legs` one it copies.
|
||||||
|
|
||||||
|
### engagement_rules — the operator's configuration (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| trigger_id | VARCHAR(96) NOT NULL | a declared trigger id. **No FK and no existence check** — a trigger is declared in code, so a rule naming one no module currently registers is *dormant*, never deleted ([`ENGAGEMENT.md`](ENGAGEMENT.md) §7.3) |
|
||||||
|
| name | VARCHAR(160) NOT NULL | |
|
||||||
|
| enabled | TINYINT(1) NOT NULL DEFAULT **0** | off by default, so no import, seed or restore can start mailing on its own (§7.1 Q3) |
|
||||||
|
| audience | VARCHAR(32) NOT NULL DEFAULT 'owner' | a ceiling name — `owner` / `staff` / `subscribers` / `members` / `authenticated` / `everyone` |
|
||||||
|
| audience_segment_id | INT NULL | a composed segment (§5.1a). **Deliberately no FK** — see below |
|
||||||
|
| max_sends_per_hour | INT NOT NULL DEFAULT 100 | the hard per-rule ceiling (§7.1 Q3), counted in `engagement_sends` and enforced before an outbox row is written |
|
||||||
|
| channels | JSON NOT NULL | `['email','inapp']` — a rule may span channels |
|
||||||
|
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5 |
|
||||||
|
| conditions | JSON NULL | a small closed and/or/not grammar over the trigger's **declared** variables |
|
||||||
|
| cooldown_seconds | INT NOT NULL DEFAULT 0 | 0 = no cooldown |
|
||||||
|
| delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) |
|
||||||
|
| cancel_on | JSON NULL | trigger ids that cancel a pending row for the same subject |
|
||||||
|
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`INDEX(trigger_id, enabled)` — the engine's one indexed read per emit.
|
||||||
|
|
||||||
|
**`audience_segment_id` carries no foreign key on purpose.** The two options a database offers are
|
||||||
|
both wrong here: `ON DELETE CASCADE` would delete an operator's rules, and `ON DELETE SET NULL` would
|
||||||
|
silently fall the rule back to its plain `audience` column — and that fallback reaches a **different
|
||||||
|
set of people**, which is the failure §5.1a rule 4 exists to prevent. A rule whose segment is gone is
|
||||||
|
dormant and sends nothing, and deleting a segment a rule still uses is refused in the model.
|
||||||
|
|
||||||
|
### engagement_audience_segments — operator-composed audiences (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| name | VARCHAR(160) NOT NULL | |
|
||||||
|
| expression | JSON NOT NULL | a boolean tree of module-declared audience ids + params |
|
||||||
|
| ceiling | VARCHAR(32) NOT NULL | **derived, never operator-typed** — the narrowest ceiling in the tree |
|
||||||
|
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
**Composition narrows, never widens.** `A OR B` takes the *tighter* of the two ceilings, not the
|
||||||
|
looser: a ceiling states what an expression is allowed to reach, not what it will resolve to, so the
|
||||||
|
boolean operator's direction is irrelevant. Two incomparable ceilings have no meet and the save is
|
||||||
|
refused rather than resolved to a guess (`src/modules/ceilings.js`). `not` is legal only inside an
|
||||||
|
`and` — a complement needs a set to be taken from, and "everyone except…" is a broadcast built out of
|
||||||
|
a narrow audience — and it contributes no ceiling of its own, since excluding people cannot widen.
|
||||||
|
|
||||||
|
The ceiling is a **stored column rather than a runtime computation** so an audit can read what a rule
|
||||||
|
was allowed to reach without re-resolving it, and so a module that later widens its own audience's
|
||||||
|
ceiling cannot retroactively widen a segment saved under the old one.
|
||||||
|
|
||||||
|
### engagement_cooldowns — one fire per (rule, user, subject) (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||||
|
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject |
|
||||||
|
| last_fired_at | DATETIME NOT NULL | |
|
||||||
|
| fire_count | INT NOT NULL DEFAULT 1 | |
|
||||||
|
|
||||||
|
`PRIMARY KEY(rule_id, user_id, subject_key)`, `INDEX(last_fired_at)` for a prune.
|
||||||
|
|
||||||
|
**`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the
|
||||||
|
wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on
|
||||||
|
(rule, user) alone silently drops three of them.
|
||||||
|
|
||||||
|
**The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the
|
||||||
|
interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single
|
||||||
|
`INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb
|
||||||
|
connector's default `foundRows: true` makes a no-op update report 1 rather than 0 — under which every
|
||||||
|
cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
|
||||||
|
|
||||||
|
### engagement_outbox — the send queue (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
|
||||||
|
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
|
||||||
|
| 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 |
|
||||||
|
| 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` |
|
||||||
|
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
|
||||||
|
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
|
||||||
|
| status | ENUM('scheduled','sending','sent','failed','cancelled','suppressed') | |
|
||||||
|
| due_at | DATETIME NOT NULL | the grace window's clock, and the retry backoff's |
|
||||||
|
| attempts / last_error / sent_at | | |
|
||||||
|
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures |
|
||||||
|
|
||||||
|
`UNIQUE(rule_id, user_id, channel, dedupe_key)`, `INDEX(status, due_at)`,
|
||||||
|
`INDEX(rule_id, user_id, subject_key, status)`.
|
||||||
|
|
||||||
|
**The unique key is scoped, and a global one would have been a data-loss bug.** A dedupe key names the
|
||||||
|
*event*; one event legitimately becomes one row per (rule, user, channel), so a fifty-person audience
|
||||||
|
on two channels is a hundred rows carrying the same key. A global `UNIQUE(dedupe_key)` admits the first
|
||||||
|
and silently ignores the rest.
|
||||||
|
|
||||||
|
**A row is claimed with a compare-and-set** — `UPDATE … SET status='sending' WHERE id=? AND
|
||||||
|
status='scheduled'` — and the sweeper the server reports `affectedRows = 1` to owns it (§7.1 Q2). That
|
||||||
|
makes the outbox safe for two app instances; the other four workers in this codebase are still
|
||||||
|
single-instance, so the deployment as a whole is not. A row stranded in `sending` by a crashed process
|
||||||
|
is reclaimed after a window, because `status='scheduled'` would otherwise never match it again.
|
||||||
|
|
||||||
|
### engagement_sends — the send log (engagement phase 4a)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| outbox_id | BIGINT NULL | |
|
||||||
|
| rule_id | INT NULL | |
|
||||||
|
| trigger_id | VARCHAR(96) NOT NULL | |
|
||||||
|
| user_id | INT NULL FK→users(id) **ON DELETE SET NULL** | the log survives an account deletion |
|
||||||
|
| channel / transport | VARCHAR(32) | which channel, and which mail transport actually carried it |
|
||||||
|
| address_hash | CHAR(64) NULL | sha256 — enough to correlate a bounce (Phase 9), useless as a mailing list |
|
||||||
|
| status | ENUM('sent','failed','suppressed','bounced','complained') | |
|
||||||
|
| detail | VARCHAR(500) NULL | |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
`INDEX(trigger_id, created_at)`, `INDEX(user_id, created_at)`, `INDEX(rule_id, created_at)` — the last
|
||||||
|
of those is the per-rule hourly ceiling's count, which runs once per rule per event.
|
||||||
|
|
||||||
|
G15: "did user X get the mail?" has never been answerable on this deployment. A row is written for
|
||||||
|
**every terminal outcome**, not only success — "no, and here is why" is an answer this table has to be
|
||||||
|
able to give — and the hourly ceiling counts only `sent`, so a broken transport cannot silently consume
|
||||||
|
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
|
||||||
|
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
|
||||||
|
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)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | INT AUTO_INCREMENT PK | |
|
||||||
|
| `key` | VARCHAR(96) NOT NULL **UNIQUE** | the stable id a rule's `template_keys` map and `mailer` name |
|
||||||
|
| name | VARCHAR(160) NOT NULL | what the admin list shows |
|
||||||
|
| trigger_id / trigger_version | VARCHAR(96) NULL / INT NULL | **no foreign key**, for the reason `engagement_rules.trigger_id` has none: a trigger is declared in code. NULL = a reusable template not tied to one trigger, which is what every transactional seed is |
|
||||||
|
| channel | VARCHAR(32) NOT NULL | one template per channel; a rule names a set |
|
||||||
|
| subject | VARCHAR(300) NULL | email only, and it interpolates. NULL is how a non-email template says it has none |
|
||||||
|
| blocks | MEDIUMTEXT NOT NULL | a JSON block array, validated + sanitized on write against the `email.*` registry — never raw operator HTML |
|
||||||
|
| text_body | MEDIUMTEXT NULL | an authored plain-text part that **replaces** the generated one; NULL = generated from each block's `toText` |
|
||||||
|
| status | ENUM('draft','published') DEFAULT 'draft' | |
|
||||||
|
| protected | TINYINT(1) DEFAULT 0 | editable, not deletable — the `pages.protected` flag, for the same reason: the system breaks without a password-reset body |
|
||||||
|
| seed_key / seed_version / customized | VARCHAR(96) NULL / INT NULL / TINYINT(1) DEFAULT 0 | the "ship a better default without stealing an operator's work" mechanism — see below |
|
||||||
|
| updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
|
||||||
|
| created_at / updated_at | DATETIME | |
|
||||||
|
|
||||||
|
`INDEX(trigger_id, channel, status)`, `INDEX(seed_key)`.
|
||||||
|
|
||||||
|
**The three seed columns are one mechanism, and the guard lives in SQL.** On boot the seeder runs an
|
||||||
|
`INSERT IGNORE` per shipped template and, when the row already exists, a single
|
||||||
|
`UPDATE … WHERE seed_key = ? AND customized = 0 AND seed_version < ?`. A read-then-write would leave a
|
||||||
|
window in which a concurrent boot overwrites an edit an operator made a moment earlier; putting
|
||||||
|
`customized = 0` in the UPDATE's own WHERE closes it. (MariaDB's `ON DUPLICATE KEY UPDATE` cannot carry
|
||||||
|
a WHERE, which is why this is two statements rather than the upsert ENGAGEMENT.md §4.6.1 sketches.) A
|
||||||
|
customized row whose shipped default has moved on is **surfaced**, never applied.
|
||||||
|
|
||||||
|
**A missing or unusable row renders the shipped default rather than nothing.** `renderByKey` falls back
|
||||||
|
to the in-code seed whenever the row is absent or its `blocks` will not parse — before the first seed
|
||||||
|
runs, after a restore that dropped the table, or on a row hand-edited in the database. That fallback is
|
||||||
|
what makes it safe for a password-reset mail to depend on this table at all.
|
||||||
|
|
||||||
|
### user_notifications — the in-app inbox (engagement phase 7)
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT AUTO_INCREMENT PK | |
|
||||||
|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | CASCADE, unlike `engagement_sends`: this is content addressed to a person, not an audit of what the deployment sent |
|
||||||
|
| trigger_id | VARCHAR(96) NOT NULL | denormalized, **no foreign key** — a trigger is declared in code |
|
||||||
|
| title | VARCHAR(300) NOT NULL | rendered from the template's first `email.heading`; falls back to the projected `title`, then to the key. Truncated rather than refused |
|
||||||
|
| body | TEXT NULL | the **text** render of the template's remaining blocks. Not the email HTML — see below |
|
||||||
|
| url | VARCHAR(500) NULL | **site-relative only**, validated with the same character class `pageUrlTemplate` and the engine's `url` variables use. An absolute url on this deployment's own base is reduced to a relative one; anything else is dropped to NULL |
|
||||||
|
| dedupe_key | VARCHAR(190) NULL | NULL = this item does not dedupe |
|
||||||
|
| read_at | DATETIME NULL | |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
`UNIQUE (user_id, dedupe_key)`, `INDEX(user_id, read_at, created_at)`, `INDEX(created_at)`.
|
||||||
|
|
||||||
|
**The unique key is scoped to the USER, and that is deliberately narrower than the outbox's.**
|
||||||
|
`engagement_outbox` scopes its dedupe to `(rule, user, channel)` because one event legitimately becomes
|
||||||
|
one row per channel; an inbox has no channel dimension, so two rows for one event would be one item
|
||||||
|
shown twice. Multiple NULLs are permitted by a UNIQUE index, which is what "does not dedupe" means, and
|
||||||
|
`INSERT IGNORE` is what makes a replay, a retry and a module writing the same item twice all one no-op.
|
||||||
|
|
||||||
|
**`body` is text, and that is the load-bearing choice rather than a shortcut.** The `email.*` renderer
|
||||||
|
produces markup built for mail clients — table rows, inline hex colours, a light-only palette declared
|
||||||
|
with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a pale card
|
||||||
|
floating in a dark one. `toText` is the same content with none of that, and it is the part the block
|
||||||
|
contract already promises every block can produce. It also means there is no operator markup on this
|
||||||
|
surface to sanitize, and no way for one to appear: every renderer treats the column as text.
|
||||||
|
|
||||||
|
**The template maps onto the three columns by block ROLE** (`templates.renderInappByKey`): the first
|
||||||
|
`email.heading` is the title, the first `email.button` is the url, and everything else is the body. So
|
||||||
|
an operator editing `inapp.event` in the Phase 5b editor changes what appears in the inbox, which is the
|
||||||
|
only reason the template exists at all.
|
||||||
|
|
||||||
|
**Retention: `utils/userNotificationsPrune.js`, nightly, READ items only.** Age alone would delete the
|
||||||
|
evidence for "I was never told", which is the complaint this table answers, and an inbox that quietly
|
||||||
|
drops unread items is one whose badge means nothing. The horizon is `settings.user_notifications_retain_days`
|
||||||
|
(default 90), so an operator tightens a busy shard without a deploy — `team_activity`'s posture, in the
|
||||||
|
worker that file is modelled on.
|
||||||
|
|
||||||
|
### The two block registries — pages and mail (engagement phase 5a)
|
||||||
|
|
||||||
|
`server/src/blocks/` (the CMS page family) and `server/src/emailBlocks/` (`email.heading`, `email.text`,
|
||||||
|
`email.button`, `email.divider`, `email.image`, `email.itemList`) are **siblings, not one registry**.
|
||||||
|
Three reasons, in order of what they cost if ignored:
|
||||||
|
|
||||||
|
1. **Email blocks render on the server.** A page block carries `schema` / `sanitize` / `cacheTTL` and is
|
||||||
|
drawn by React in `client/src/blocks/`; a mail body is a string this process produces, so an email
|
||||||
|
definition carries `toHtml` and `toText`. `registerBlock` freezes a fixed field set and would drop
|
||||||
|
both silently.
|
||||||
|
2. **One registry would be one namespace.** The page registry's only server consumer is
|
||||||
|
`pages.model.js`; putting `email.heading` in that Map makes a CMS page containing an email block
|
||||||
|
validate and save, with nothing on the client able to draw it.
|
||||||
|
3. The entry shapes differ — `cacheTTL` and `container` mean nothing to a mail body, a renderer nothing
|
||||||
|
to a cached page block.
|
||||||
|
|
||||||
|
What *is* shared is shared by binding rather than by copy: `propHelpers`, the envelope/id/nesting walk
|
||||||
|
(`makeValidateBlocks`) and the validate-then-sanitize order (`makeSanitizeBlocks`) are factories the two
|
||||||
|
registries each bind. ENGAGEMENT.md §4.4's "do not build a second editor" is honoured where it is about
|
||||||
|
the editor — Phase 5b drives the `email.*` family through the existing block/prop-panel machinery.
|
||||||
|
|
||||||
|
### Template variables — the token grammar (engagement phase 5a)
|
||||||
|
|
||||||
|
`{{ name }}`, a bare declared variable name, and nothing else: no filters, no conditionals, no loops, no
|
||||||
|
dotted paths. Repetition is a block (`email.itemList` renders a declared *list* variable), which is why
|
||||||
|
the grammar needs no loop. Three consequences worth knowing before authoring one:
|
||||||
|
|
||||||
|
- **Interpolation is HTML-escaped in the HTML part and raw in the text part.** There is no raw-HTML
|
||||||
|
variable type (§4.6.2) — a module supplies data, not markup.
|
||||||
|
- **A URL built from a variable is re-checked after substitution.** A stored `{{resetUrl}}` says nothing
|
||||||
|
about where it points; a substituted value that is not http(s)/same-origin loses its href and renders
|
||||||
|
as inert text rather than as a link a reader has no reason to distrust.
|
||||||
|
- **Presentational conditionals live at the call site**, not in the template. `mailer` computes
|
||||||
|
` for the account “Darrow”` with a ternary and passes the *result* as a variable, whose declared
|
||||||
|
`example` shows exactly what it produces.
|
||||||
|
|
||||||
|
Four **ambient** variables — `siteName`, `siteUrl`, `logoUrl`, `year` — are available to every template
|
||||||
|
and are merged **over** whatever a caller passes. A caller supplies the message; the deployment supplies
|
||||||
|
its identity, and letting a caller override it would mean mail that claims to be from somewhere else.
|
||||||
|
|
||||||
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
|
### mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)
|
||||||
|
|
||||||
@@ -837,7 +1135,12 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
|
|||||||
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
|
| POST | `/me/devices` | cookie / bearer | `{endpoint, transport?, platform?}` | register a push endpoint; **rejects a disallowed endpoint 400** (SSRF guard). Idempotent per (user, endpoint) |
|
||||||
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
| GET | `/me/devices` · DELETE `…/:id` | cookie / bearer | — | list / unregister own push devices |
|
||||||
| 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/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
|
||||||
@@ -995,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. |
|
||||||
|
|
||||||
@@ -1068,6 +1372,21 @@ file a route sits in — that is the property the route manifest freezes.
|
|||||||
| POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` |
|
| POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` |
|
||||||
| DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted |
|
| DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted |
|
||||||
| GET | `/engagement/triggers` | every **declared** event trigger, its payload contract (each variable with a type, a `required` flag and an `example`) and its audience **ceiling** — plus the ceiling vocabulary itself and the closed variable-type set. Served from the module registries, **not from a table**: a trigger is declared in code by core or an installed module, so this is whatever registered on this boot and a module that was uninstalled simply stops appearing. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.3 |
|
| GET | `/engagement/triggers` | every **declared** event trigger, its payload contract (each variable with a type, a `required` flag and an `example`) and its audience **ceiling** — plus the ceiling vocabulary itself and the closed variable-type set. Served from the module registries, **not from a table**: a trigger is declared in code by core or an installed module, so this is whatever registered on this boot and a module that was uninstalled simply stops appearing. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.3 |
|
||||||
|
| GET | `/engagement/audiences` | every declared audience a rule may be pointed at, with its params and ceiling. The `resolve` function is **never served** — an audience answers with user ids on the server side only, so a module still cannot enumerate addresses. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
|
||||||
|
| GET | `/engagement/channels` | every registered delivery channel a rule may send on, with its `defaultMode`. From the delivery-channel registry, so the rule editor offers exactly the set the save path checks and a module-registered channel appears with no client release. `adminOnly` |
|
||||||
|
| GET | `/engagement/audience-preview` | `?audience=` **or** `?audienceSegmentId=`, plus an optional `?triggerId=`. Runs the **same resolver the engine runs** and answers `{count, capped, ceiling, dormant, reason, permitted}` — a **count only**, never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true at the 5000-row audience bound, where the count is a floor and not a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries; `permitted` is whether the trigger's G24 ceiling allows the reach just counted. `adminOnly` |
|
||||||
|
| GET · POST | `/engagement/rules` | list every rule annotated with **dormancy** (and why), or create one. A new rule must name a currently-registered trigger, arrives `enabled: 0` (§7.1 Q3) and has its audience checked against that trigger's ceiling. `400` carries every problem in `errors[]`, not just the first. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.5 |
|
||||||
|
| GET · PUT · DELETE | `/engagement/rules/:id` | read, replace or delete one rule. The **trigger is not updatable** — a rule's cooldowns, its pending outbox rows and its send-log history are all about one trigger id, and re-pointing it would silently re-attribute all three. An existing rule may keep naming an unregistered trigger, so a dormant rule stays editable. `DELETE` cascades its cooldowns and pending outbox rows; `engagement_sends` carries **no foreign key**, so the send log outlives the rule |
|
||||||
|
| PATCH | `/engagement/rules/:id/enabled` | flip that column and no other, **without re-validating the rule**. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating `PUT` refuses to save. Turning one on is safe unvalidated because the engine re-checks the ceiling at send time |
|
||||||
|
| GET · POST | `/engagement/segments` | list every saved audience segment annotated with dormancy (and which audience ids are missing), or save a new one. The stored `ceiling` is **derived** as the narrowest in the expression and is never taken from the caller; `not` is legal only as a child of `and`; two incomparable ceilings have no meet and the composition is refused rather than guessed. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
|
||||||
|
| PUT · DELETE | `/engagement/segments/:id` | update (re-deriving the ceiling) or delete. **`409` while any rule still points at it**, with the count in the message. No foreign key does this on purpose: `CASCADE` would delete an operator's rules and `SET NULL` would silently fall each rule back to its plain `audience` column, which reaches a *different set of people* |
|
||||||
|
| GET | `/engagement/templates` | every message template, each annotated with three separately-meaningful warnings: `dormant` (pinned to a trigger no installed module declares, so its variables cannot be checked and nothing will send it), `triggerBehind` (the module is installed but its declaration has moved on past the version this template was authored against) and `seedBehind` (a newer shipped default exists and was **not** applied, because a person had edited this row). `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.6.2 |
|
||||||
|
| GET | `/engagement/templates/:id` | one template plus `variables` — the palette the editor offers, resolved from the trigger declaration or, for a template tied to no trigger, from the shipped seed, merged with the ambient variables every template may use. Served with the row so the editor never guesses what is legal |
|
||||||
|
| PUT | `/engagement/templates/:id` | edit any template, **including a shipped default, in place**: the save sets `customized = 1`, which is what stops the next seed bump from taking the edit back. `key` and `channel` are **immutable and the attempt is refused rather than ignored** — `mailer` renders by key, so a rename would break the message it names with no error anywhere. Two refusals are the point of the route: a token (or an `email.itemList` naming a bare variable) referencing something the trigger does not declare is refused **with the variable named**, and a `published` template whose plain-text part renders empty is refused — checked by *rendering* with the declared examples, because whether a text part exists depends on what each block's `toText` does with these props |
|
||||||
|
| POST | `/engagement/templates/:id/duplicate` | the **only** way a template that is not a shipped seed comes into being, so every template on a deployment descends from one that renders. The copy always starts as a `draft`, is never `protected`, and **inherits the source's `seed_key`** — that is what carries its variable palette, not bookkeeping: a seedless, triggerless copy would resolve to the ambient variables alone and be refused for the tokens it was copied with. `409` on a taken key |
|
||||||
|
| DELETE | `/engagement/templates/:id` | `409` for a `protected` template — the system breaks without a password-reset body, so those are editable and not deletable — and `409` while any rule's `template_keys` points at the key, **naming the rules**. The same answer a segment in use gets, for the same reason: the alternative is a rule that silently stops producing mail |
|
||||||
|
| POST | `/engagement/templates/:id/preview` | renders the body **in the request**, not the stored row, using each variable's declared `example` — which is why `example` is a required part of a trigger declaration rather than documentation. A `POST` that writes nothing: an editor that could only preview what was already saved would make saving the way to find out whether a change was right. Returns both parts as JSON strings; the client renders the HTML inside `<iframe sandbox="" srcdoc>` with **no `allow-scripts`**. Serving it as a document from this origin would run operator-authored HTML under the site's own CSP with access to its cookies |
|
||||||
|
| POST | `/engagement/templates/:id/test-send` | sends what is on screen, saved or not, through the configured transport, and records the attempt in `engagement_sends` **including when it fails** — the outcome an operator most needs a record of. `trigger_id` is `NOT NULL` and a transactional template has no trigger, so the row is logged under the synthetic **`core.admin.test-send`**, which is deliberately not a registered trigger. It does not consult channel preferences or the suppression list: the address is typed by an admin about their own deployment and is not derived from a user. `409` when mail is unconfigured, `502` when the relay refuses |
|
||||||
| GET | `/engagement/sends` | the send log, newest first, paged (`limit` 1–200, `offset`) and filterable by `triggerId`, `ruleId`, `userId` and `status`, with a `total` matching the same filters. **G15's answer.** `address_hash` is stored but **never returned**: the log keeps it so a bounce can be correlated back to a recipient (Phase 9) without becoming a second address book, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment |
|
| GET | `/engagement/sends` | the send log, newest first, paged (`limit` 1–200, `offset`) and filterable by `triggerId`, `ruleId`, `userId` and `status`, with a `total` matching the same filters. **G15's answer.** `address_hash` is stored but **never returned**: the log keeps it so a bounce can be correlated back to a recipient (Phase 9) without becoming a second address book, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment |
|
||||||
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
|
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
|
||||||
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
|
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
|
||||||
@@ -1234,6 +1553,19 @@ must land an admin on a screen that says "unconfigured", not a 500 that takes th
|
|||||||
must land an admin on a screen that says "unconfigured", not a 500 that takes the contact form with
|
must land an admin on a screen that says "unconfigured", not a 500 that takes the contact form with
|
||||||
it. `provider` and `refresh_token_enc` remain as **deprecated, unread columns** under the
|
it. `provider` and `refresh_token_enc` remain as **deprecated, unread columns** under the
|
||||||
additive-only discipline.
|
additive-only discipline.
|
||||||
|
|
||||||
|
**What each sender still owns is its recipient, its headers and its failure contract — not what it
|
||||||
|
says.** Engagement Phase 5a moved every subject and body out of `mailer.js` into `engagement_templates`
|
||||||
|
rows (§4.6.1); the file's five senders call one seam, `engagement/templates.renderByKey`, which falls
|
||||||
|
back to the shipped seed when the row is missing or unusable. Two consequences:
|
||||||
|
|
||||||
|
- **Mail is now `multipart/alternative`.** Nothing here had an HTML part before. The **text part is
|
||||||
|
byte-identical** to what the deleted literals built — pinned by `test/emailTemplates.test.js`, whose
|
||||||
|
expected strings *are* those literals — and the HTML part is new, table-based and inline-styled.
|
||||||
|
- **Subjects now resolve the deployment's own name.** They interpolate `{{siteName}}`, which is
|
||||||
|
`settings.getInstanceName()` — the admin-set `site_title`, falling back to `BRAND_NAME`. On a
|
||||||
|
deployment that never set a site title nothing changes; on one that did, the subject finally says
|
||||||
|
what the site calls itself.
|
||||||
|
|
||||||
**No phone-home.** No transport may ship a default host, port, endpoint or sender
|
**No phone-home.** No transport may ship a default host, port, endpoint or sender
|
||||||
([`ENGAGEMENT.md`](ENGAGEMENT.md) §3.2). A transport with no operator configuration is
|
([`ENGAGEMENT.md`](ENGAGEMENT.md) §3.2). A transport with no operator configuration is
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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