feat(teams): phase 6 — notifications, and the email sink the web never had #156

Merged
whitlocktech merged 6 commits from feature/teams-phase6-notifications into edge 2026-08-18 23:10:22 +00:00
12 changed files with 651 additions and 7 deletions
Showing only changes of commit 2a56cbf22a - Show all commits

View File

@@ -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",

View File

@@ -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"

View File

@@ -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 } }
}
/**

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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 users 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 users 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

View File

@@ -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')
}

View File

@@ -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 }

View File

@@ -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 `<v>.<uid>.<tid>.<mac>`.
//
// 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'
// #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 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

View File

@@ -8364,6 +8364,114 @@
}
}
},
"/api/v1/auth/me/notifications/teams": {
"get": {
"tags": [
"Auth · Me"
],
"summary": "Get the current users 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 users 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 Teams 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 sites 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 sites 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": {

View File

@@ -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',