Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.
Seven decisions settled by the org lead before any code:
- email only moves; the push tickle and the Discord bridge stay direct calls
- the EVENT carries its access-checked audience, and `members` resolves to it
- the four Team rules are seeded DISABLED, with an admin banner and a note
- team_notification_prefs stays, read by the engine as a scoped preference
- the payload wins and a structural projection fills the gaps
- the digest keeps computing at send time; only its state generalizes
- an unsubscribe token turns off the channel it names, and nothing else
Three defects found while building it:
- `email.button` never absolutized its href, while image and itemList both
did. Every rule-driven CTA would have been a dead relative link, because a
trigger's url variables are validated site-relative by construction.
- Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
not to build. An outbox row snapshots the payload and so has none of the
three properties the digest design exists for, including the security one.
- the digest's send-log row carried no address_hash while the instant row
beside it did, which would have made half the mail uncorrelatable in Phase 9.
Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.
Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
68 lines
2.7 KiB
JavaScript
68 lines
2.7 KiB
JavaScript
const { query } = require('../../utils/db')
|
|
|
|
const listByUser = (userId) =>
|
|
query(
|
|
'SELECT stream_id, channel, mode FROM notification_channel_prefs WHERE user_id = ? ORDER BY stream_id, channel',
|
|
[userId],
|
|
)
|
|
|
|
// One (user, stream, channel) row. Upsert rather than insert-or-update in app
|
|
// code: the primary key is exactly the triple, so MariaDB decides, and two
|
|
// concurrent PUTs from a phone and a browser cannot race into a duplicate-key
|
|
// error.
|
|
const upsert = (userId, streamId, channel, mode) =>
|
|
query(
|
|
`INSERT INTO notification_channel_prefs (user_id, stream_id, channel, mode)
|
|
VALUES (?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE mode = VALUES(mode)`,
|
|
[userId, streamId, channel, mode],
|
|
)
|
|
|
|
// Every push row this user holds that is NOT in `keep`, set to 'off'. The legacy
|
|
// whole-set PUT's other half: it says "these streams and no others", and the
|
|
// rows it is silent about have to stop meaning 'instant'.
|
|
//
|
|
// It sets rather than deletes, so a user's explicit "no" survives a later change
|
|
// to push's `defaultMode` (§3.1 / channels.js). Deleting would fold "I turned
|
|
// this off" back into "I never said", and those are the same thing only for as
|
|
// long as the default happens to be 'off'.
|
|
async function offPushExcept(userId, keep) {
|
|
if (!keep.length) {
|
|
return query(
|
|
"UPDATE notification_channel_prefs SET mode = 'off' WHERE user_id = ? AND channel = 'push' AND mode <> 'off'",
|
|
[userId],
|
|
)
|
|
}
|
|
const marks = keep.map(() => '?').join(', ')
|
|
return query(
|
|
`UPDATE notification_channel_prefs SET mode = 'off'
|
|
WHERE user_id = ? AND channel = 'push' AND mode <> 'off' AND stream_id NOT IN (${marks})`,
|
|
[userId, ...keep],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Turn one channel off for every named stream — the deployment-wide unsubscribe
|
|
* (ENGAGEMENT.md Phase 6).
|
|
*
|
|
* It WRITES a row per stream rather than updating the rows that happen to exist,
|
|
* and the difference is the same one `offPushExcept` argues: absence means the
|
|
* channel's `defaultMode`, so updating only what is there would leave a user
|
|
* unsubscribed today and re-subscribed the day a channel ships a non-off default.
|
|
* An unsubscribe has to be a statement, not the absence of one.
|
|
*/
|
|
async function offForChannel(userId, channel, streamIds) {
|
|
const ids = [...new Set(streamIds)].filter((s) => typeof s === 'string' && s)
|
|
if (!ids.length) return null
|
|
const values = ids.map(() => '(?, ?, ?, ?)').join(', ')
|
|
const params = ids.flatMap((streamId) => [userId, streamId, channel, 'off'])
|
|
return query(
|
|
`INSERT INTO notification_channel_prefs (user_id, stream_id, channel, mode)
|
|
VALUES ${values}
|
|
ON DUPLICATE KEY UPDATE mode = VALUES(mode)`,
|
|
params,
|
|
)
|
|
}
|
|
|
|
module.exports = { listByUser, upsert, offPushExcept, offForChannel }
|