feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
The same Team event as §6, delivered a third time: push, email, and now a Discord channel the operator configured. Not a second pipeline — teamNotify.js already computed the recipient set once, so the bridge is a sink beside the two that were there. The design's gate has no data source. §7.2 bridges an event only if "its visibility is public, or its destination channel is configured for a members-only Team context". The four team.* streams carry no visibility; forum threads have no public/members column because a forum is members-only by construction; and core cannot see a Discord channel's permissions. So §7.2's own example config names exactly the two events that are never public. The gate is therefore an attributed operator acknowledgement, in the shape teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet drop at delivery — it is re-asked at delivery as well as at the save, and changing the channel clears it, because an acknowledgement is about a destination and cannot survive the destination changing underneath it. The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every override overrides — is unrepresentable. Proved on a real MariaDB (error 1048). Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the unique key, and the foreign key the original had no room for. One-shot, not queued: "identical to announce and mod-reverse" names two different reliability models, and a Team notification is the moment it describes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ const forum = require('../../../model/teams/teamForum.model')
|
||||
const forumDb = require('../../../model/teams/teamForum.db')
|
||||
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const integration = require('../../../model/teams/teamIntegration.model')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
@@ -265,7 +266,91 @@ async function decideRequest(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── The integration bridge (§7.2, phase 8) — admin only ───────────────────
|
||||
//
|
||||
// Admin-only at the ROUTER, unlike everything above it. The §2.9 gate exists
|
||||
// because a moderator's action publishes untrusted game strings to the public
|
||||
// site; this is a different risk in the other direction — it decides that
|
||||
// members-only forum text leaves the site altogether, for a destination core
|
||||
// cannot see. That is a deployment-configuration decision, and it sits with the
|
||||
// role that holds the bot token rather than with the queue.
|
||||
|
||||
async function integrationConfig(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
platform: integration.DISCORD,
|
||||
events: integration.BRIDGEABLE.map((id) => ({ id, membersOnly: integration.isMembersOnly(id) })),
|
||||
rows: await integration.list(integration.DISCORD),
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'integration config')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIntegrationConfig(req, res) {
|
||||
try {
|
||||
// `teamId` null is the deployment default and is a legitimate body, so the
|
||||
// absent-vs-null distinction matters: a PUT with no teamId edits the default.
|
||||
const teamId = req.body.teamId === undefined || req.body.teamId === null ? null : Number(req.body.teamId)
|
||||
if (teamId !== null && !(await teamsDb.findById(teamId))) {
|
||||
return res.status(404).json({ message: 'Team not found' })
|
||||
}
|
||||
|
||||
const row = await integration.save(
|
||||
{
|
||||
platform: integration.DISCORD,
|
||||
teamId,
|
||||
events: req.body.events,
|
||||
channelRef: req.body.channelRef,
|
||||
enabled: req.body.enabled,
|
||||
membersAck: req.body.membersAck,
|
||||
},
|
||||
req.user.id,
|
||||
)
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.integration.save',
|
||||
detail:
|
||||
`${req.user.username} (#${req.user.id}) saved the ${integration.DISCORD} bridge for ` +
|
||||
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}: ` +
|
||||
`${row.enabled ? 'enabled' : 'disabled'}, events [${row.events.join(', ')}]` +
|
||||
`${row.members_ack ? ', members-only destination acknowledged' : ''}`,
|
||||
})
|
||||
|
||||
return res.json(row)
|
||||
} catch (err) {
|
||||
// A validation refusal carries its own status and its own wording — the
|
||||
// acknowledgement message in particular is the whole explanation of why the
|
||||
// save was refused, and collapsing it into a 500 would leave the operator
|
||||
// with a screen that will not save and no reason given.
|
||||
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
|
||||
return fail(res, err, 'save integration config')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIntegrationConfig(req, res) {
|
||||
try {
|
||||
const teamId = req.params.teamId === 'default' ? null : Number(req.params.teamId)
|
||||
const removed = await integration.remove(integration.DISCORD, teamId)
|
||||
if (removed === 0) return res.status(404).json({ message: 'No configuration for that Team' })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.integration.delete',
|
||||
detail:
|
||||
`${req.user.username} (#${req.user.id}) removed the ${integration.DISCORD} bridge for ` +
|
||||
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}`,
|
||||
})
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'delete integration config')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
integrationConfig,
|
||||
saveIntegrationConfig,
|
||||
deleteIntegrationConfig,
|
||||
forumModeration,
|
||||
forumUploads,
|
||||
forumSettingsState,
|
||||
|
||||
@@ -19,9 +19,16 @@ const { body, param, query } = require('express-validator')
|
||||
|
||||
const ctrl = require('./teams.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const teamsRouter = express.Router()
|
||||
|
||||
// The one ADMIN-only corner of a staff-wide router (§7.2, phase 8). Configuring
|
||||
// where a Team's events leave the site for is not the §2.9 kind of decision a
|
||||
// moderator files a request for; it is deployment configuration, and it sits with
|
||||
// the role that already holds the bot token.
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
// ── Literal paths, first ───────────────────────────────────────────────────
|
||||
|
||||
teamsRouter.get(
|
||||
@@ -123,6 +130,54 @@ teamsRouter.get(
|
||||
ctrl.forumSettingsState,
|
||||
)
|
||||
|
||||
// ── The integration bridge (§7.2) — literal, and before /:id ──────────────
|
||||
|
||||
teamsRouter.get(
|
||||
'/integrations',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The Team notification bridge’s configuration (admin only)'
|
||||
// #swagger.description = 'Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Team’s members — recorded here with who gave it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Bridge configuration', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationConfig" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.integrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.put(
|
||||
'/integrations',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Create or replace one bridge destination (admin only)'
|
||||
// #swagger.description = 'Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channel’s permissions, so the operator’s acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The saved row', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationRow" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[422] = { description: 'Not enableable — no channel, no events, or a members-only event without the acknowledgement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('teamId').optional({ nullable: true }).isInt({ min: 1 }).toInt(),
|
||||
body('events').isArray({ max: 8 }),
|
||||
body('channelRef').optional({ nullable: true }).isString().trim().isLength({ max: 64 }),
|
||||
body('enabled').optional().isBoolean().toBoolean(),
|
||||
body('membersAck').optional().isBoolean().toBoolean(),
|
||||
validate,
|
||||
ctrl.saveIntegrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.delete(
|
||||
'/integrations/:teamId',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Remove one bridge destination (admin only)'
|
||||
// #swagger.description = 'Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.'
|
||||
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Team id, or the literal string default.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('teamId').custom((v) => v === 'default' || /^[0-9]+$/.test(v)),
|
||||
validate,
|
||||
ctrl.deleteIntegrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
|
||||
Reference in New Issue
Block a user