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>
135 lines
9.4 KiB
JavaScript
135 lines
9.4 KiB
JavaScript
// Public · Teams — the anonymous Team surface (TEAMS.md §2.11).
|
||
//
|
||
// Mounted at /api/v1/public/teams by public/index.js. No group gate: this is the
|
||
// anonymous surface, and `siteMode` is applied per route as everywhere else in
|
||
// this tier — during maintenance only an admin with a valid session sees content.
|
||
//
|
||
// Declaration order: '/' is literal and precedes the two :slug routes, and
|
||
// '/:slug/members' is deeper than '/:slug', so nothing here can shadow anything
|
||
// else.
|
||
|
||
const express = require('express')
|
||
|
||
const ctrl = require('./teams.controller')
|
||
const siteMode = require('../../../middleware/siteMode')
|
||
const { optionalAuth } = require('../../../auth/session.middleware')
|
||
|
||
const teamsRouter = express.Router()
|
||
|
||
teamsRouter.get(
|
||
'/',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'List active, publicly visible Teams'
|
||
// #swagger.description = 'Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.'
|
||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
|
||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||
/* #swagger.responses[200] = { description: 'Publicly visible Teams, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamList" } } } } */
|
||
siteMode,
|
||
ctrl.listTeams,
|
||
)
|
||
|
||
// Declared before the ':slug' routes. It cannot be shadowed by them — it has
|
||
// three path segments and they have one or two — but keeping it above makes the
|
||
// relationship visible to whoever adds the next route here.
|
||
teamsRouter.get(
|
||
'/by-external/:moduleId/:externalId',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Get one Team by the owning module’s own identifier'
|
||
// #swagger.description = 'Exists so a module’s page can find core’s Team without holding core’s identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module, so the scope is what stops one module reading another’s Team by guessing a serial. A hidden Team returns 404, like every other public lookup.'
|
||
// #swagger.parameters['moduleId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module that owns the Team.' }
|
||
// #swagger.parameters['externalId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'That module’s own identifier for it.' }
|
||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
siteMode,
|
||
ctrl.getTeamByExternalId,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/:slug',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Get one Team by slug'
|
||
// #swagger.description = '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.'
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
siteMode,
|
||
ctrl.getTeam,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/:slug/members',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Get a Team roster'
|
||
// #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published, whatever the module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.'
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
siteMode,
|
||
optionalAuth,
|
||
ctrl.getRoster,
|
||
)
|
||
|
||
// The one route in this tier that reads the caller's identity. `optionalAuth`
|
||
// serves anonymous callers rather than rejecting them, and identifies an
|
||
// authenticated one properly enough that a banned or logged-out account drops
|
||
// back to the public half of the feed at once (TEAMS.md §4.3).
|
||
teamsRouter.get(
|
||
'/:slug/activity',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'A Team’s activity feed, filtered to what the caller may see'
|
||
// #swagger.description = 'Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render "some items are hidden" rather than presenting a filtered feed as the whole one. Sending a session is optional.'
|
||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 100 (default 50).' }
|
||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'One page of the feed', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamActivity" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden from this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
siteMode,
|
||
optionalAuth,
|
||
ctrl.getActivity,
|
||
)
|
||
|
||
// ── One-click unsubscribe — the LEGACY path (TEAMS.md §6.4) ────────────────
|
||
//
|
||
// The canonical pair now lives at `/public/engagement/unsubscribe/:token`
|
||
// (ENGAGEMENT.md Phase 6). These two stay, permanently, and hand straight to the
|
||
// same handlers: mail sent before that phase carries this path in its
|
||
// `List-Unsubscribe` header, and a route that moves is a person who cannot
|
||
// unsubscribe.
|
||
//
|
||
// Declared last, and the shadowing question is worth answering rather than
|
||
// assuming: these are two segments, so the one-segment '/:slug' cannot take them,
|
||
// and the two-segment '/:slug/members' and '/:slug/activity' both pin a LITERAL
|
||
// second segment. Only a token spelled exactly "members" or "activity" could
|
||
// collide, and a token is `<v>.<uid>.<tid>.<mac>` (v1) or
|
||
// `<v>.<uid>.<channel>.<scope>.<mac>` (v2).
|
||
//
|
||
// No `siteMode`, unlike every other route in this file. An unsubscribe has to work
|
||
// while the site is in maintenance: the mail that carried the link went out before
|
||
// the site went down, and "we are doing maintenance" is not an answer to "stop
|
||
// emailing me".
|
||
teamsRouter.post(
|
||
'/unsubscribe/:token',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Unsubscribe from one Team’s notification emails (legacy path)'
|
||
// #swagger.description = 'The pre-Phase-6 path, kept permanently because links in mail already sent point at it. Identical to POST /public/engagement/unsubscribe/{token}. Honours the tokened link including RFC 8058 one-click; a token signed before Phase 6 turns off that Team’s email and no longer mutes its push. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.'
|
||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||
// #swagger.security = [{}]
|
||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||
ctrl.unsubscribe,
|
||
)
|
||
|
||
teamsRouter.get(
|
||
'/unsubscribe/:token',
|
||
// #swagger.tags = ['Public · Teams']
|
||
// #swagger.summary = 'Land a human on the unsubscribe page'
|
||
// #swagger.description = 'For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.'
|
||
// #swagger.parameters['token'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The signed token from the email link.' }
|
||
// #swagger.security = [{}]
|
||
/* #swagger.responses[302] = { description: 'Redirect to the site’s unsubscribe page' } */
|
||
ctrl.unsubscribeLanding,
|
||
)
|
||
|
||
module.exports = teamsRouter
|