// Public · Teams — the anonymous read surface (TEAMS.md §2.11). // // Every handler here is a projection over core's own tables; nothing calls the // module. A Team page must render while the shard is down, showing a roster // marked stale, because that is what the projection is for. const teams = require('../../../model/teams/teams.model') const teamActivity = require('../../../model/teams/teamActivity.model') const teamPrefs = require('../../../model/teams/teamNotify.model') const unsubscribeToken = require('../../../utils/unsubscribeToken') const log = require('../../../utils/logger')('teams') const fail = (res, err, what) => { log.error(`public teams: ${what} failed`, { message: err.message }) return res.status(500).json({ message: 'Internal Server Error' }) } async function listTeams(req, res) { try { const limit = Math.min(Number.parseInt(req.query.limit, 10) || 50, 200) const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0) return res.json(await teams.listPublic({ limit, offset })) } catch (err) { return fail(res, err, 'list') } } async function getTeam(req, res) { try { const team = await teams.getPublic(req.params.slug) // A hidden Team is indistinguishable from a missing one here, deliberately: // "absent from every public surface" includes not confirming it exists. if (!team) return res.status(404).json({ message: 'Team not found' }) return res.json(team) } catch (err) { return fail(res, err, 'get') } } /** * One Team, named the way the calling MODULE names it (§3.4 as amended). * * The one route that exists purely so a module's page can find core's Team * without holding core's identifiers. Both parameters come from the path and the * module id is MATCHED, not trusted: `external_id` is unique only within a * module, so scoping the lookup is what stops one module reading another's Team * by guessing a serial. */ async function getTeamByExternalId(req, res) { try { const team = await teams.getPublicByExternalId(req.params.moduleId, req.params.externalId) if (!team) return res.status(404).json({ message: 'Team not found' }) return res.json(team) } catch (err) { return fail(res, err, 'by-external') } } /** * The roster, projected for whoever is asking (§3.3). * * The viewer is described to the module rather than handed over: it gets the * caller's id and role, which is what a rung decision turns on, and not the user * row — a module has `ctx.users.getById` if it needs more, and passing the whole * record here would make every column of `users` part of this contract. */ async function getRoster(req, res) { try { const viewer = req.user ? { userId: req.user.id, role: req.user.role } : null const roster = await teams.rosterPublic(req.params.slug, viewer) if (!roster) return res.status(404).json({ message: 'Team not found' }) return res.json(roster) } catch (err) { return fail(res, err, 'roster') } } /** * A Team's activity feed (§4.3). * * The only handler in this tier that reads `req.user`, and it reads nothing else * from the caller about what they may see: `limit` and `offset` are page * controls, and the visibility filter is resolved from the session alone. A * request parameter naming its own visibility is the bug the ENUM exists to * prevent, so there is deliberately no way to ask for one. * * The cap is 100 rather than the index's 200 — every row carries a summary and an * opaque payload, so a page of these is much larger than a page of Teams. */ async function getActivity(req, res) { try { const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 50, 1), 100) const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0) const feed = await teamActivity.feedFor(req.params.slug, req.user ? req.user.id : null, { limit, offset }) if (!feed) return res.status(404).json({ message: 'Team not found' }) return res.json(feed) } catch (err) { return fail(res, err, 'activity') } } /** * POST /public/teams/unsubscribe/:token — one-click unsubscribe (TEAMS.md §6.4). * * **The one write in this tier, and it is unauthenticated on purpose.** A person * reading their mail is not logged into the site, and an unsubscribe that first * demands a login is an unsubscribe most people do not complete. The token is what * stands in for the session, and the capability it carries is deliberately the * narrowest one that does the job: set `muted` for ONE (user, Team) pair. It reads * nothing, cannot un-mute, and names no other Team. * * **Always 200, whatever the token was.** A response that distinguished a valid * token from a forged one would turn this into an oracle for which (user, Team) * pairs exist, on an endpoint with no session behind it. The page says "you will * not receive further emails about this team" either way, which is true either way. * * Reached two ways with the same effect: a mail client's RFC 8058 one-click POST * (the `List-Unsubscribe-Post` header), and the site's own /unsubscribe page, * which POSTs here after a human clicks the link in the body. */ async function unsubscribe(req, res) { const claim = unsubscribeToken.verify(req.params.token) if (claim) { try { await teamPrefs.mute(claim.userId, claim.teamId) } catch (err) { // Logged, not surfaced. A failed write here is worth an operator's // attention and is not worth telling an anonymous caller about — and a 500 // would make a mail client retry a request it should not repeat. log.error('unsubscribe', err) } } return res.json({ ok: true }) } /** * GET on the same path — for a mail client that shows the `List-Unsubscribe` URL * as a link and has no one-click support. * * Redirects to the site's own page rather than acting, because a GET must not * mutate: a link prefetcher or a mail client's link scanner would otherwise * silently mute Teams nobody asked to leave. The page it lands on does the POST * once a human is looking at it. */ function unsubscribeLanding(req, res) { const base = (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') return res.redirect(302, `${base}/unsubscribe/${encodeURIComponent(req.params.token)}`) } module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity, unsubscribe, unsubscribeLanding }