feat(teams): fire the four events, and the routes that configure them
The roster sync tickles at most ONCE per stream per run, not once per member: a tickle is content-free, so five people joining in one sweep is five identical notifications and one piece of information. Suppressed on a Team's FIRST roster, the same condition the activity feed uses and the half where it matters more — importing a 155-member guild would otherwise wake every one of their phones. Forum notifications fire from the CONTROLLER, not from the forum model. That file takes an already-resolved access decision and reads no membership table by design; the fan-out reads both to compute its recipients, so calling it from inside would make the forum model transitively depend on exactly what its header says it must not touch. The model returns a `notify` key the controller destructures out before the response, so the API's answer to "did my post save" is unchanged. `pageUrlTemplate` joins the team provider — the one thing phase 6 found that the design of record had not anticipated. Phase 3 left core with no Team page and therefore no way to LINK to one, so a notification email could name a Team and not take you to it. It is data rather than a callback: a function would put a module hook on the mail path to produce a string that never varies. Relative paths only, and protocol-relative is refused with absolute. The unsubscribe endpoint is the only write in the public tier and the only route with no `siteMode` — the reader is in their mail client, and the mail went out before the site went into maintenance. POST always answers 200, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET redirects and acts on nothing, so a mail client's link scanner cannot mute Teams nobody asked to leave. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user