feat(teams): the notification core — four streams, and a recipient set
Phase 6's foundation: the fan-out shape the existing pipeline could not express. `pushDispatch.publish` answers "everyone subscribed to a stream" and "this one owner". Team notifications need "these N users", because Team scoping cannot live in a stream id: the catalog is a static registration validated at boot against a namespaced pattern, so a stream per Team is unexpressible, and stream ids are stored in `notification_subscriptions` rows that would need collecting every time a Team archived. So there are FOUR fixed core streams and the Team lives entirely in the recipient set. `team_notification_prefs` is opt-out for push and opt-IN for email — the two sinks default opposite ways, and the asymmetry lives in the column defaults so no condition anywhere has to remember it. One recipient query serves all four streams, because §6.2's two populations are the same set written twice: "active members with a user_id plus active grants" IS "everyone with resolved forum access". Mutes are subtracted in SQL rather than by the caller — there is no function here that returns an unfiltered set. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1266,6 +1266,45 @@ CREATE TABLE IF NOT EXISTS team_activity (
|
||||
INDEX idx_team_activity_feed (team_id, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Per-Team notification preference (TEAMS.md §6.3/§6.4, phase 6). OPT-OUT, not
|
||||
-- opt-in: a user in a single Team must never have to configure anything, so the
|
||||
-- absence of a row is the default and every column here is a deviation from it.
|
||||
--
|
||||
-- Team scoping lives HERE and in the recipient computation, never in a stream id.
|
||||
-- The push catalog is a static registration validated at boot against a namespaced
|
||||
-- pattern; it cannot express one stream per Team, and stream ids are stored in
|
||||
-- notification_subscriptions rows that would then need garbage-collecting every
|
||||
-- time a Team archived. Four fixed streams plus this table is the same feature
|
||||
-- with nothing to collect.
|
||||
--
|
||||
-- `last_digest_at` is the digest's ONLY state. There is no queue of pending items:
|
||||
-- the worker asks what arrived after this timestamp and re-runs the access
|
||||
-- resolver, so a deployment that was down for a day sends one correct digest
|
||||
-- rather than replaying a backlog, and a user who lost forum access between the
|
||||
-- post and the send is not emailed content they can no longer read.
|
||||
CREATE TABLE IF NOT EXISTS team_notification_prefs (
|
||||
user_id INT NOT NULL,
|
||||
team_id INT NOT NULL,
|
||||
muted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- 'off', and NOT the design-of-record's 'digest'. Digest-by-default would mean
|
||||
-- every member of every Team starts receiving daily mail the moment an operator
|
||||
-- connects Gmail, which is a decision about other people's inboxes made on their
|
||||
-- behalf. Email is therefore the one sink here that is opt-IN; the mute is still
|
||||
-- opt-out, because a mute silences something the user already asked for.
|
||||
--
|
||||
-- It also keeps this column honest as a deviation-from-default: a row written to
|
||||
-- set `muted` alone leaves email exactly where it was.
|
||||
email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off',
|
||||
last_digest_at DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, team_id),
|
||||
CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
-- The digest worker's driving query is "rows in digest mode, oldest send first",
|
||||
-- which is a scan of this index rather than of every preference ever written.
|
||||
INDEX idx_tnp_digest (email_mode, last_digest_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
Reference in New Issue
Block a user