Files
website/server/src/router/v1/public/teams.router.js
wtclaude 065bec7ad8
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 11m9s
feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
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>
2026-08-29 20:11:54 -05:00

135 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 modules own identifier'
// #swagger.description = 'Exists so a modules page can find cores Team without holding cores 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 anothers 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 modules 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 modules projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the modules 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 Teams 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 Teams 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 Teams 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 sites 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 sites unsubscribe page' } */
ctrl.unsubscribeLanding,
)
module.exports = teamsRouter