feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -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