diff --git a/server/routes.guards.json b/server/routes.guards.json index 2d8b6dc..343ac05 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -1387,6 +1387,26 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/auth/me/notifications/teams", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/auth/me/notifications/teams", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/auth/me/sessions", @@ -1944,6 +1964,18 @@ "siteMode" ] }, + { + "method": "GET", + "path": "/api/v1/public/teams/unsubscribe/:token", + "handlers": 1, + "gates": [] + }, + { + "method": "POST", + "path": "/api/v1/public/teams/unsubscribe/:token", + "handlers": 1, + "gates": [] + }, { "method": "GET", "path": "/api/v1/public/version", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index b6be23f..30bf058 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -549,6 +549,14 @@ "method": "PUT", "path": "/api/v1/auth/me/notifications/subscriptions" }, + { + "method": "GET", + "path": "/api/v1/auth/me/notifications/teams" + }, + { + "method": "PUT", + "path": "/api/v1/auth/me/notifications/teams" + }, { "method": "GET", "path": "/api/v1/auth/me/sessions" @@ -789,6 +797,14 @@ "method": "GET", "path": "/api/v1/public/teams/by-external/:moduleId/:externalId" }, + { + "method": "GET", + "path": "/api/v1/public/teams/unsubscribe/:token" + }, + { + "method": "POST", + "path": "/api/v1/public/teams/unsubscribe/:token" + }, { "method": "GET", "path": "/api/v1/public/version" diff --git a/server/src/model/teams/teamForum.model.js b/server/src/model/teams/teamForum.model.js index a5d8506..daa9692 100644 --- a/server/src/model/teams/teamForum.model.js +++ b/server/src/model/teams/teamForum.model.js @@ -217,7 +217,17 @@ async function createThread({ team, actor, type, title, body }) { authorUsername: actor.username, bodyHtml: cleaned, }) - return { ok: true, threadId, postId } + // `notify` is what the CONTROLLER needs to fan a notification out, and it is a + // separate key rather than more fields on the result because the controller + // spreads the result straight into the response body — a notification's excerpt + // is not part of the API's answer to "did my post save". + // + // The notification itself is fired from the controller and not from here, on + // this file's own rule (see the header): everything in it takes an + // already-resolved access decision and reads no membership table. The fan-out + // reads both, so importing it here would make the forum model transitively + // depend on exactly what it exists not to touch. + return { ok: true, threadId, postId, notify: { threadId, title, type, bodyHtml: cleaned } } } /** @@ -256,7 +266,11 @@ async function createPost({ team, threadId, actor, body }) { authorUsername: actor.username, bodyHtml: cleaned, }) - return { ok: true, threadId, postId } + // The thread's OWN title and type, not the reply's — a reply has neither, and + // what a recipient needs to know is which conversation moved. `type` is always + // 'discussion' here (an announcement takes no replies) and is carried anyway so + // the controller has one shape to hand the fan-out from both routes. + return { ok: true, threadId, postId, notify: { threadId, title: thread.title, type: thread.type, bodyHtml: cleaned } } } /** diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js index 067f41f..395dbaa 100644 --- a/server/src/model/teams/teamSync.model.js +++ b/server/src/model/teams/teamSync.model.js @@ -33,6 +33,7 @@ const teamsDb = require('./teams.db') const teamProvider = require('./teamProvider') const moderation = require('./teamModeration.model') const activity = require('./teamActivity.model') +const teamNotify = require('../../utils/teamNotify') const { slugify, uniqueSlug } = require('./teamSlug') const settings = require('../settings/settings.model') const log = require('../../utils/logger')('teams') @@ -191,6 +192,34 @@ async function logRosterActivity(team, { joined, left, promoted, demoted }) { } } +/** + * The push half of the same roster run (TEAMS.md §6.2, phase 6). + * + * **At most one tickle per stream per run, not one per member.** A tickle is + * content-free — it says "something happened in this Team" and the app pulls the + * rest — so five people joining in one sweep is five identical notifications and + * one piece of information. The feed above is per-member because it is a record; + * this is per-run because it is a nudge. + * + * **Suppressed on a Team's FIRST roster, exactly as the feed is**, and this is the + * half where it matters more: importing a 155-member guild would otherwise wake + * every one of their phones. `roster_synced_at IS NULL` is the same condition, read + * from the same row before the same stamp moves. + * + * Never throws — the fan-out swallows its own failures, and this adds the guard + * for anything the surrounding read could raise. A roster sync is the source of + * truth; a notification about it is not. + */ +async function notifyRoster(team, { joined, promoted, demoted }) { + if (!team.roster_synced_at) return + try { + if (joined.length > 0) await teamNotify.memberJoined(team) + if (promoted.length > 0 || demoted.length > 0) await teamNotify.leadershipChanged(team) + } catch (err) { + log.warn('roster notification not sent', { teamId: team.id, message: err.message }) + } +} + /** * Sync one Team's roster and leadership. Gates 3 and 4 live here. * @@ -285,8 +314,10 @@ async function syncRoster(team) { } await teamsDb.recount(team.id) - // Read before `markRosterSynced` moves the stamp this decision turns on. + // Both read before `markRosterSynced` moves the stamp their first-roster + // suppression turns on. await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted }) + await notifyRoster(team, { joined, promoted, demoted }) await teamsDb.markRosterSynced(team.id) return true } diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index e0fa24c..21a77a7 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -255,6 +255,10 @@ function checkLegShape(entry) { // implementing it means core fails CLOSED when the call cannot be made, so this // is a member to add deliberately rather than by habit. // +// `pageUrlTemplate` is the fifth, also OPTIONAL, and is data rather than a method +// — see its own comment below. A module that omits it costs its deployment +// clickable links in Team notification email and nothing else. +// // The copy is explicit rather than a spread: this object is what core calls, so // anything not named here is not part of the contract and must not survive // registration. A method that silently rode along would look implemented from the @@ -274,9 +278,43 @@ function checkTeamProviderShape(entry) { } out.projectRoster = provider.projectRoster } + if (provider.pageUrlTemplate !== undefined) { + out.pageUrlTemplate = checkPageUrlTemplate(provider.pageUrlTemplate) + } return out } +// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6). +// +// **Why a module has to supply this at all.** Teams are a contract primitive with +// no core surface: core owns the tables and the access rules, and the MODULE owns +// the page, because core does not own the word for a Team. That is settled and +// right — but it leaves core unable to write a link to one, and a notification +// email that cannot link to the thread it is about is most of the way to useless. +// So the module that owns the page says where it is. +// +// **A template, not a callback.** Core substitutes `{externalId}` and `{slug}` +// into a relative path and does nothing else with it. A function would be a +// module hook on the mail path — one more thing that can hang or throw between a +// forum reply and the mail about it — to produce a string that never varies. +// +// Validated hard, because the output goes into an email as a link. Relative only: +// a template naming its own host would let a module redirect the site's outbound +// mail somewhere else, and there is no reason for one to. +// One leading slash, and the second character may not be another. `//evil.test/x` +// passes an "is it rooted" check and is a PROTOCOL-RELATIVE url — core prefixing +// its own base makes it harmless today, but a template is a string that ends up +// in an href sooner or later, and this is a character class rather than a +// judgement call about who concatenates it. +const PAGE_URL_TEMPLATE = /^\/(?!\/)[A-Za-z0-9\-._~/{}]*$/ + +function checkPageUrlTemplate(value) { + if (typeof value !== 'string' || !PAGE_URL_TEMPLATE.test(value)) { + throw new Error(`registerTeamProvider: pageUrlTemplate must be a relative path, got "${value}"`) + } + return value +} + /** * `registerPostHook({ onSaved, onDeleted })` — both optional, at least one * required. A registration with neither is a subscription that can never fire, diff --git a/server/src/router/v1/auth/notifications.controller.js b/server/src/router/v1/auth/notifications.controller.js index a05e19d..f2b4889 100644 --- a/server/src/router/v1/auth/notifications.controller.js +++ b/server/src/router/v1/auth/notifications.controller.js @@ -6,6 +6,7 @@ const pushDevices = require('../../../model/pushDevices/pushDevices.model') const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model') const registries = require('../../../modules/registries') +const teamPrefs = require('../../../model/teams/teamNotify.model') const { isAllowedEndpoint } = require('../../../utils/pushDispatch') const log = require('../../../utils/logger')('notifications') @@ -78,6 +79,39 @@ async function putSubscriptions(req, res) { } } +// GET /auth/me/notifications/teams — this user's per-Team preferences, one row +// per Team they could be notified about whether or not they have ever set one. +// +// Not gated on `teams_forums_enabled`: two of the four streams (member joined, +// leadership changed) have nothing to do with the forum, so a deployment with +// forums switched off still has preferences worth showing. +async function getTeamPrefs(req, res) { + try { + return res.json({ teams: await teamPrefs.listPrefs(req.user.id) }) + } catch (err) { + log.error('getTeamPrefs', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /auth/me/notifications/teams — replace the caller's whole preference set. +// +// PUT-the-whole-set, matching the subscriptions endpoint beside it, and the +// `teams` array is REQUIRED even when empty — the Android gotcha in +// docs/android/PLAN.md §11: a DTO field with a default is dropped by kotlinx when +// it equals that default, so clearing the last entry would arrive as a body with +// no array at all and 400. Entries naming a Team the caller is not in are dropped +// by the model rather than refused here (an ordinary race, not a client bug). +async function putTeamPrefs(req, res) { + try { + const { prefs } = await teamPrefs.replacePrefs(req.user.id, req.body.teams) + return res.json({ teams: prefs }) + } catch (err) { + log.error('putTeamPrefs', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + module.exports = { registerDevice, listDevices, @@ -85,4 +119,6 @@ module.exports = { getStreams, getSubscriptions, putSubscriptions, + getTeamPrefs, + putTeamPrefs, } diff --git a/server/src/router/v1/auth/notifications.routes.js b/server/src/router/v1/auth/notifications.routes.js index 579777f..d3b6731 100644 --- a/server/src/router/v1/auth/notifications.routes.js +++ b/server/src/router/v1/auth/notifications.routes.js @@ -13,6 +13,7 @@ const notif = require('./notifications.controller') const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') +const { EMAIL_MODES } = require('../../../model/teams/teamNotify.model') const notifRouter = express.Router() @@ -97,4 +98,39 @@ notifRouter.put( notif.putSubscriptions, ) +// ── Per-Team preferences (TEAMS.md §6.3, phase 6) ────────────────────────── +// +// The granularity per-stream opt-in cannot express: "I am in five Teams and want +// notifications from one". Opt-OUT for push (no row means notified) and opt-IN +// for email, so a user who never opens this screen is in the state the schema +// documents rather than in one this router has to describe. +notifRouter.get( + '/notifications/teams', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Get the current user’s per-Team notification preferences' + // #swagger.description = 'One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` "off".' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Per-Team preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + notif.getTeamPrefs, +) + +notifRouter.put( + '/notifications/teams', + // #swagger.tags = ['Auth · Me'] + // #swagger.summary = 'Replace the current user’s per-Team notification preferences' + // #swagger.description = 'Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */ + /* #swagger.responses[200] = { description: 'Updated preferences', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamNotificationPrefs" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('teams').isArray(), + body('teams.*.teamId').isInt({ min: 1 }), + body('teams.*.muted').optional().isBoolean(), + body('teams.*.emailMode').optional().isIn(EMAIL_MODES), + validate, + notif.putTeamPrefs, +) + module.exports = notifRouter diff --git a/server/src/router/v1/player/teamForum.controller.js b/server/src/router/v1/player/teamForum.controller.js index 204c248..ac3250a 100644 --- a/server/src/router/v1/player/teamForum.controller.js +++ b/server/src/router/v1/player/teamForum.controller.js @@ -26,6 +26,7 @@ const forumSettings = require('../../../model/teams/teamForumSettings.model') const uploads = require('../../../model/teams/teamForumUploads.model') const reports = require('../../../model/reports/contentReports.model') const activity = require('../../../model/activity/activity.model') +const teamNotify = require('../../../utils/teamNotify') const log = require('../../../utils/logger')('teams') @@ -86,6 +87,34 @@ async function viewerFor(ctx, user) { } } +/** + * Fan a new thread or reply out to the Team (TEAMS.md Part 6, phase 6). + * + * **Here rather than in the forum model**, because the model takes an + * already-resolved access decision and reads no membership table by design, and + * the fan-out reads both to compute its recipients. A notification call inside the + * model would make it transitively depend on what its own header says it must not. + * + * **Awaited, and it still cannot fail the request.** `teamNotify.forumPost` catches + * everything and returns; awaiting it costs the response the time of one recipient + * query plus, in `immediate` mode, the SMTP calls — which is why the alternative + * (fire-and-forget) is tempting and wrong here: an un-awaited rejection in an + * Express handler is an unhandled rejection, and the tests would have no moment at + * which to assert the fan-out happened. + */ +async function announce(ctx, actor, notify) { + if (!notify) return + await teamNotify.forumPost({ + team: ctx.team, + threadId: notify.threadId, + threadTitle: notify.title, + type: notify.type, + authorUserId: actor.id, + authorName: actor.username, + bodyHtml: notify.bodyHtml, + }) +} + // ── threads ──────────────────────────────────────────────────────────────── async function listThreads(req, res) { @@ -151,13 +180,14 @@ async function createThread(req, res) { return res.status(403).json({ message: 'Only Team leaders may post announcements' }) } - const result = await forum.createThread({ + const { notify, ...result } = await forum.createThread({ team: ctx.team, actor: req.user, type, title: req.body.title, body: req.body.body, }) + if (result.ok) await announce(ctx, req.user, notify) return send(res, result) } catch (err) { return fail(res, err, 'create thread') @@ -170,12 +200,14 @@ async function createPost(req, res) { const ctx = await resolveForum(req) if (!ctx) return res.status(404).json({ message: 'Not found' }) - return send(res, await forum.createPost({ + const { notify, ...result } = await forum.createPost({ team: ctx.team, threadId: Number(req.params.id), actor: req.user, body: req.body.body, - })) + }) + if (result.ok) await announce(ctx, req.user, notify) + return send(res, result) } catch (err) { return fail(res, err, 'create post') } diff --git a/server/src/router/v1/public/teams.controller.js b/server/src/router/v1/public/teams.controller.js index d2be486..b41825f 100644 --- a/server/src/router/v1/public/teams.controller.js +++ b/server/src/router/v1/public/teams.controller.js @@ -6,6 +6,8 @@ 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') @@ -98,4 +100,52 @@ async function getActivity(req, res) { } } -module.exports = { listTeams, getTeam, getTeamByExternalId, getRoster, getActivity } +/** + * 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 } diff --git a/server/src/router/v1/public/teams.router.js b/server/src/router/v1/public/teams.router.js index 5723ad2..118ae81 100644 --- a/server/src/router/v1/public/teams.router.js +++ b/server/src/router/v1/public/teams.router.js @@ -90,4 +90,38 @@ teamsRouter.get( ctrl.getActivity, ) +// ── One-click unsubscribe (TEAMS.md §6.4) ────────────────────────────────── +// +// 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 `...`. +// +// 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' + // #swagger.description = 'Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. 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 diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 8c0e56c..5cdc2dd 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -8364,6 +8364,114 @@ } } }, + "/api/v1/auth/me/notifications/teams": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "Get the current user’s per-Team notification preferences", + "description": "One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` \"off\".", + "responses": { + "200": { + "description": "Per-Team preferences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamNotificationPrefs" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Auth · Me" + ], + "summary": "Replace the current user’s per-Team notification preferences", + "description": "Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.", + "responses": { + "200": { + "description": "Updated preferences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamNotificationPrefs" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamNotificationPrefs" + } + } + } + } + } + }, "/api/v1/auth/me/sessions": { "get": { "tags": [ @@ -12070,6 +12178,67 @@ } } }, + "/api/v1/public/teams/unsubscribe/{token}": { + "post": { + "tags": [ + "Public · Teams" + ], + "summary": "Unsubscribe from one Team’s notification emails", + "description": "Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The signed token from the email link." + } + ], + "responses": { + "200": { + "description": "Acknowledged", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkFlag" + } + } + } + } + }, + "security": [ + {} + ] + }, + "get": { + "tags": [ + "Public · Teams" + ], + "summary": "Land a human on the unsubscribe page", + "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.", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The signed token from the email link." + } + ], + "responses": { + "302": { + "description": "Redirect to the site’s unsubscribe page" + } + }, + "security": [ + {} + ] + } + }, "/api/v1/public/teams/{slug}": { "get": { "tags": [ @@ -16081,6 +16250,143 @@ } } }, + "TeamNotificationPref": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`)." + }, + "properties": { + "type": "object", + "properties": { + "teamId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "the-silver-hand" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "The Silver Hand" + } + } + }, + "archived": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "muted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "emailMode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "off", + "digest", + "immediate" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "off" + } + } + } + } + } + } + }, + "TeamNotificationPrefs": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty." + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamNotificationPref" + } + } + } + } + } + } + }, "Appeal": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 6ec430f..62dbc12 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -573,6 +573,25 @@ const doc = { }, }, }, + TeamNotificationPref: { + type: 'object', + description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).", + properties: { + teamId: { type: 'integer', example: 3 }, + slug: { type: 'string', example: 'the-silver-hand' }, + name: { type: 'string', example: 'The Silver Hand' }, + archived: { type: 'boolean', example: false }, + muted: { type: 'boolean', example: false }, + emailMode: { type: 'string', enum: ['off', 'digest', 'immediate'], example: 'off' }, + }, + }, + TeamNotificationPrefs: { + type: 'object', + description: 'Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty.', + properties: { + teams: { type: 'array', items: { $ref: '#/components/schemas/TeamNotificationPref' } }, + }, + }, // ── Moderation appeals (Phase 6c/6d) ──────────────────────────────────── Appeal: { type: 'object',