From 11b4368b579a7040eb7b3f169ee2a4aa8181a2d4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 20:25:30 -0500 Subject: [PATCH] =?UTF-8?q?feat(teams):=20phase=208=20=E2=80=94=20the=20no?= =?UTF-8?q?tifications=20bridge,=20and=20the=20gate=20=C2=A77.2=20could=20?= =?UTF-8?q?not=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bot/src/discord/teamNotify.js | 80 ++++ bot/src/internal/internal.controller.js | 41 +- bot/src/internal/internal.routes.js | 1 + bot/test/teamNotify.test.js | 138 ++++++ client/src/api/client.js | 6 + client/src/lib/teamIntegrations.js | 103 ++++ .../routes/admin/views/TeamIntegrations.jsx | 281 +++++++++++ client/src/routes/admin/views/TeamsAdmin.jsx | 6 + client/test/teamIntegrations.test.js | 129 +++++ server/db/schema.sql | 48 ++ server/routes.guards.json | 31 ++ server/routes.manifest.json | 12 + server/src/model/teams/teamIntegration.db.js | 122 +++++ .../src/model/teams/teamIntegration.model.js | 272 +++++++++++ server/src/model/teams/teamSync.model.js | 5 +- .../src/router/v1/admin/teams.controller.js | 85 ++++ server/src/router/v1/admin/teams.router.js | 55 +++ server/src/utils/botInternalClient.js | 19 +- server/src/utils/teamBridge.js | 120 +++++ server/src/utils/teamNotify.js | 49 +- server/swagger/swagger-output.json | 439 ++++++++++++++++++ server/swagger/swagger.js | 41 ++ server/test/teamBridge.test.js | 197 ++++++++ server/test/teamIntegration.test.js | 291 ++++++++++++ server/test/teamNotifyDispatch.test.js | 7 +- 25 files changed, 2567 insertions(+), 11 deletions(-) create mode 100644 bot/src/discord/teamNotify.js create mode 100644 bot/test/teamNotify.test.js create mode 100644 client/src/lib/teamIntegrations.js create mode 100644 client/src/routes/admin/views/TeamIntegrations.jsx create mode 100644 client/test/teamIntegrations.test.js create mode 100644 server/src/model/teams/teamIntegration.db.js create mode 100644 server/src/model/teams/teamIntegration.model.js create mode 100644 server/src/utils/teamBridge.js create mode 100644 server/test/teamBridge.test.js create mode 100644 server/test/teamIntegration.test.js diff --git a/bot/src/discord/teamNotify.js b/bot/src/discord/teamNotify.js new file mode 100644 index 0000000..09ce99f --- /dev/null +++ b/bot/src/discord/teamNotify.js @@ -0,0 +1,80 @@ +// Team notifications posted into an operator-configured channel (TEAMS.md §7.2). +// +// **The channel comes from the app, not from guild_config.** `newsAnnounce` looks +// its channel up here because there is exactly one #news; a Team's destination is +// per-Team configuration living in `team_integration_config`, and a bot that +// resolved it would need a second copy of that table and a second place for it to +// drift. The app sends the id it already decided on. +// +// **Everything this file knows about a Team it was told.** No lookups, no +// membership checks, no access decisions: whether this content may reach this +// channel was settled on the site, where the acknowledgement that gates it lives. +// The bot is the transport, exactly as it is for slash commands. +const { EmbedBuilder } = require('discord.js') + +const brand = require('../brand') +const createLogger = require('../utils/logger') + +const log = createLogger('team-notify') + +// Discord's own limits. Truncating here rather than trusting the app is not +// distrust — an embed that exceeds them is rejected wholesale, and a message +// silently not appearing is the worst failure mode this path has. +const TITLE_MAX = 256 +const DESCRIPTION_MAX = 4096 + +const clamp = (value, max) => { + const text = String(value || '').trim() + if (!text) return null + return text.length > max ? `${text.slice(0, max - 1)}…` : text +} + +// What each stream is called in a channel. The app composes the BODY; this is +// only the label above it, and it is here because it is Discord presentation — +// the same reason the embed colour is. +const HEADINGS = { + 'team.member.joined': 'New member', + 'team.leadership.changed': 'Leadership change', + 'team.forum.post': 'New forum post', + 'team.announcement': 'Announcement', +} + +async function postTeamNotification(client, { channelId, stream, teamName, teamUrl, title, body, url }) { + if (!channelId) throw new Error('No channel id supplied.') + + const channel = await client.channels.fetch(channelId).catch(() => null) + if (!channel || !channel.isTextBased()) { + throw new Error('Configured channel is missing, not text-based, or not visible to the bot.') + } + + const heading = HEADINGS[stream] || 'Team update' + const name = clamp(teamName, 120) || 'A team' + + const embed = new EmbedBuilder() + .setColor(brand.accentInt) + // The Team is the AUTHOR line and the event is the title, not the other way + // round: a channel carrying one Team's events would otherwise repeat its name + // as every heading, and a channel carrying several needs the name to be the + // thing the eye lands on first. + .setAuthor(teamUrl ? { name, url: teamUrl } : { name }) + .setTitle(clamp(title, TITLE_MAX) || heading) + + if (url) embed.setURL(url) + + // Both a title and a body means a forum post: the heading has to go somewhere + // or "New forum post" and "Announcement" become indistinguishable once the + // thread title takes the title slot. + // + // **Clamped AFTER the heading is prepended, not before.** Clamping the body and + // then adding a prefix produces a description one heading longer than the limit, + // which discord.js rejects outright — so an over-long post would not arrive at + // all rather than arriving truncated. The prefix is part of what has to fit. + const composed = title && body ? `**${heading}**\n${String(body)}` : body + const description = clamp(composed, DESCRIPTION_MAX) + if (description) embed.setDescription(description) + + await channel.send({ embeds: [embed] }) + log.info('team notification posted', { stream, channelId, team: name }) +} + +module.exports = { postTeamNotification, HEADINGS, clamp, TITLE_MAX, DESCRIPTION_MAX } diff --git a/bot/src/internal/internal.controller.js b/bot/src/internal/internal.controller.js index c55e286..5c236ee 100644 --- a/bot/src/internal/internal.controller.js +++ b/bot/src/internal/internal.controller.js @@ -1,5 +1,6 @@ const discordManager = require('../discord/discordManager') const newsAnnounce = require('../discord/newsAnnounce') +const teamNotify = require('../discord/teamNotify') const modLog = require('../discord/modLog') const createLogger = require('../utils/logger') @@ -121,4 +122,42 @@ async function refreshCommands(req, res) { } } -module.exports = { setConfig, getStatus: getStatusHandler, announce, reverseModAction, refreshCommands } +// POST /internal/team-notify — a Team notification the site has already decided +// belongs in a channel (TEAMS.md §7.2). Body: { channel_id, stream, team_name, +// team_url, title, body, url }. +// +// **The site chose the channel and the site checked the access.** Whether +// members-only forum text may reach this channel is an acknowledgement recorded +// against team_integration_config, and re-deciding it here would mean the bot +// holding a copy of a policy it cannot see the inputs to. +// +// 503 when disconnected and 400 for a channel the bot cannot post to, matching +// /internal/announce — the caller is one-shot and best-effort and only logs the +// difference, but an operator debugging a silent channel needs the two to read +// differently in the bot's log. +async function teamNotifyHandler(req, res) { + const connection = discordManager.getConnection() + if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) + + const { channel_id: channelId, stream, team_name: teamName, team_url: teamUrl, title, body, url } = req.body || {} + if (!channelId || !stream) { + return res.status(400).json({ message: 'channel_id and stream are required' }) + } + + try { + await teamNotify.postTeamNotification(connection.client, { channelId, stream, teamName, teamUrl, title, body, url }) + return res.json({ posted: true }) + } catch (err) { + log.warn('team-notify failed', { message: err.message, stream, channelId }) + return res.status(400).json({ message: err.message }) + } +} + +module.exports = { + setConfig, + getStatus: getStatusHandler, + announce, + reverseModAction, + refreshCommands, + teamNotify: teamNotifyHandler, +} diff --git a/bot/src/internal/internal.routes.js b/bot/src/internal/internal.routes.js index 8d2f04c..cae1ff7 100644 --- a/bot/src/internal/internal.routes.js +++ b/bot/src/internal/internal.routes.js @@ -12,5 +12,6 @@ router.get('/status', ctrl.getStatus) router.post('/announce', ctrl.announce) router.post('/mod-reverse', ctrl.reverseModAction) router.post('/refresh-commands', ctrl.refreshCommands) +router.post('/team-notify', ctrl.teamNotify) module.exports = router diff --git a/bot/test/teamNotify.test.js b/bot/test/teamNotify.test.js new file mode 100644 index 0000000..d14a152 --- /dev/null +++ b/bot/test/teamNotify.test.js @@ -0,0 +1,138 @@ +// ── The bot's half of the Team notifications bridge (TEAMS.md §7.2) ──────── +// +// Nothing here talks to Discord. `channel` is a fake that records what was sent, +// and the assertions are about the three things this side genuinely owns: +// +// 1. **the channel comes from the app and is never looked up.** `newsAnnounce` +// reads guild_config because there is one #news; a Team's destination is +// per-Team configuration, and a bot that resolved it would hold a second +// copy of a table it cannot see the inputs to; +// 2. **a channel the bot cannot post to fails loudly rather than silently.** A +// caller that is one-shot and best-effort only logs the difference, but an +// operator debugging a quiet channel needs the bot's log to distinguish +// "not connected" from "that id is not a text channel"; +// 3. **Discord's own limits are enforced here.** An embed that exceeds them is +// rejected WHOLESALE, so a long forum body must be truncated on this side +// even though the app already excerpted it — the app's limit is a product +// decision and this one is a protocol constraint. + +const { test } = require('node:test') +const assert = require('node:assert/strict') + +const teamNotify = require('../src/discord/teamNotify') + +// A fake channel that records what it was sent. `isTextBased` is the one method +// the code branches on, so it is the one worth making configurable. +function fakeChannel({ textBased = true } = {}) { + const sends = [] + return { + sends, + isTextBased: () => textBased, + send: async (payload) => { sends.push(payload); return { id: 'm1' } }, + } +} + +function fakeClient(channel, { throws = false } = {}) { + return { + channels: { + fetch: async (id) => { + if (throws) throw new Error('Unknown Channel') + return id === 'chan-1' ? channel : null + }, + }, + } +} + +const post = (client, over = {}) => teamNotify.postTeamNotification(client, { + channelId: 'chan-1', + stream: 'team.forum.post', + teamName: 'Blackthorn’s Legion', + teamUrl: 'https://site/guilds/blackthorns-legion', + title: 'Siege tonight', + body: 'Meet at the moongate.', + url: 'https://site/guilds/blackthorns-legion?thread=41', + ...over, +}) + +// ── 1. The channel is the app's decision ─────────────────────────────────── + +test('the message goes to the channel the app named', async () => { + const channel = fakeChannel() + await post(fakeClient(channel)) + assert.equal(channel.sends.length, 1) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.title, 'Siege tonight') + assert.equal(embed.data.author.name, 'Blackthorn’s Legion') + assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41') +}) + +test('no channel id at all is refused before anything is fetched', async () => { + await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/) +}) + +// ── 2. A channel the bot cannot use ──────────────────────────────────────── + +test('a channel the bot cannot see is a clear error, not a silent no-op', async () => { + await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/) +}) + +test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => { + await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/) +}) + +test('a voice channel is refused', async () => { + await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/) +}) + +// ── 3. Discord's limits, and the heading ─────────────────────────────────── + +test('an over-long title is truncated rather than rejected by Discord as a whole', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { title: 'y'.repeat(400) }) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.title.length, teamNotify.TITLE_MAX) + assert.ok(embed.data.title.endsWith('…')) +}) + +test('an over-long body is truncated to the description limit', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { body: 'z'.repeat(9000) }) + const [embed] = channel.sends[0].embeds + assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32) +}) + +test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { stream: 'team.announcement' }) + const [embed] = channel.sends[0].embeds + assert.match(embed.data.description, /^\*\*Announcement\*\*/) + assert.match(embed.data.description, /Meet at the moongate\./) +}) + +test('a roster event has no title, so the heading becomes the title', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' }) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.title, 'New member') + assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one') +}) + +test('an unknown stream still posts, under a neutral heading', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { stream: 'team.something.new', title: null }) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.title, 'Team update') +}) + +test('a missing team name does not produce an embed with an empty author line', async () => { + const channel = fakeChannel() + await post(fakeClient(channel), { teamName: '', teamUrl: null }) + const [embed] = channel.sends[0].embeds + assert.equal(embed.data.author.name, 'A team') + assert.equal(embed.data.author.url, undefined) +}) + +test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => { + assert.equal(teamNotify.clamp(' ', 100), null) + assert.equal(teamNotify.clamp('ok', 100), 'ok') +}) diff --git a/client/src/api/client.js b/client/src/api/client.js index 010d9c7..c2a3490 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -340,6 +340,12 @@ export const api = { clearTeamLeaderOverride: (id, memberKey) => req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }), teamForumSettings: () => req('/admin/teams/forum/settings'), + // The notification bridge (TEAMS.md §7.2). Admin-only server-side, so a + // moderator's admin panel never renders the panel that calls these. + teamIntegrations: () => req('/admin/teams/integrations'), + saveTeamIntegration: (body) => req('/admin/teams/integrations', { method: 'PUT', body }), + deleteTeamIntegration: (teamId) => + req(`/admin/teams/integrations/${teamId === null ? 'default' : teamId}`, { method: 'DELETE' }), teamForumUploads: (opts = {}) => { const qs = new URLSearchParams() if (opts.deleted) qs.set('deleted', '1') diff --git a/client/src/lib/teamIntegrations.js b/client/src/lib/teamIntegrations.js new file mode 100644 index 0000000..b4198df --- /dev/null +++ b/client/src/lib/teamIntegrations.js @@ -0,0 +1,103 @@ +// What Admin → Teams → Notification bridge decides (TEAMS.md §7.2, phase 8). +// +// The view is a form; these are the rules it applies, extracted for the same +// reason `teamAdmin.js` is: the interesting parts are decisions — when the +// acknowledgement dialog opens, and when a standing acknowledgement stops being +// valid — and a decision embedded in JSX is one nothing can assert on. +// +// **The rules here MIRROR the server's and do not replace them.** The server +// refuses to enable a members-only bridge without the acknowledgement (422) +// whether or not this file ever ran. What is here is so the screen agrees with +// that answer before making the round trip, rather than showing an operator a +// save that fails for a reason the form did not mention. + +// Wording an operator reads, per event id the server offers. Presentation, so it +// lives on this side; the one bit that is policy — which events are members-only — +// comes from the server with each event. +export const EVENT_LABELS = { + 'team.member.joined': 'New members joined', + 'team.leadership.changed': 'Leadership changed', + 'team.forum.post': 'New forum post', + 'team.announcement': 'Announcement posted', +} + +export const eventLabel = (id) => EVENT_LABELS[id] || id + +/** A row's identity in a list. `null` and `undefined` are both the default row. */ +export const rowKey = (row) => + (row.team_id === null || row.team_id === undefined ? 'default' : String(row.team_id)) + +export const isDefaultRow = (row) => row.team_id === null || row.team_id === undefined + +export const blankDraft = (teamId = null) => ({ + teamId, + events: [], + channelRef: '', + enabled: false, + membersAck: false, +}) + +export const draftFrom = (row) => ({ + teamId: row.team_id ?? null, + events: row.events || [], + channelRef: row.channel_ref || '', + enabled: !!row.enabled, + membersAck: !!row.members_ack, +}) + +export function appliesToLabel(row, fallback = 'All Teams') { + if (isDefaultRow(row)) return fallback + return row.display_name_override || row.team_name || `Team #${row.team_id}` +} + +/** Toggle one event in a draft, preserving order of first selection. */ +export const toggleEvent = (draft, id) => ({ + ...draft, + events: draft.events.includes(id) ? draft.events.filter((e) => e !== id) : [...draft.events, id], +}) + +/** + * Repointing the row drops a standing acknowledgement, in the SAME place the + * server does. + * + * Leaving the tick showing while the server has already decided to clear it is + * the one way this screen could actively mislead: an operator repoints a row at a + * public channel, sees "members-only destination confirmed" still ticked, and + * believes the confirmation they gave for a private channel covers the new one. + */ +export function setChannel(draft, channelRef) { + if (channelRef === draft.channelRef) return draft + return { ...draft, channelRef, membersAck: false } +} + +/** Does this draft carry anything that would publish members-only text? */ +export const carriesMembersOnly = (draft, membersOnlyIds) => + draft.events.some((id) => membersOnlyIds.includes(id)) + +/** + * Should saving stop and ask first? + * + * Only when ENABLING. A draft that carries forum events but is switched off is a + * configuration being written, not a channel being published to — asking then + * would make an operator confirm something they have not decided to do yet, which + * is how a confirmation dialog becomes a thing people click through. + */ +export const needsAcknowledgement = (draft, membersOnlyIds) => + !!draft.enabled && carriesMembersOnly(draft, membersOnlyIds) && !draft.membersAck + +/** The ids of every event the server flagged as members-only. */ +export const membersOnlyIdsOf = (events) => (events || []).filter((e) => e.membersOnly).map((e) => e.id) + +/** + * Which Teams may still be given an override, and whether the default is taken. + * + * Offering a Team that already has a row would only produce a save that silently + * overwrote it, since the unique key is (platform, team). + */ +export function availableTargets(rows, teams) { + const taken = new Set(rows.filter((r) => !isDefaultRow(r)).map((r) => r.team_id)) + return { + hasDefault: rows.some(isDefaultRow), + teams: (teams || []).filter((t) => t.status === 'active' && !taken.has(t.id)), + } +} diff --git a/client/src/routes/admin/views/TeamIntegrations.jsx b/client/src/routes/admin/views/TeamIntegrations.jsx new file mode 100644 index 0000000..5f89e69 --- /dev/null +++ b/client/src/routes/admin/views/TeamIntegrations.jsx @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useState } from 'react' +import { api } from '../../../api/client.js' +import { + eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent, + setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets, +} from '../../../lib/teamIntegrations.js' + +// The Team notification bridge (TEAMS.md §7.2, phase 8). +// +// Named for the TEAM concern rather than for Discord, and placed under Teams +// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here +// with whatever the capability registry declares. What changes then should be +// what fills this panel, not where an operator goes to find it. Nothing below +// hardcodes the word except the heading the server sends as `platform`. +// +// **The checkbox in the dialog is not the gate.** The server refuses to enable a +// row carrying `team.forum.post` or `team.announcement` without the +// acknowledgement, 422, whether or not this dialog was ever rendered — the same +// division TeamForumSettings draws for image uploads. What is here is how the +// gate is PRESENTED: the sentence an operator agrees to, and the fact that +// agreeing is a deliberate act rather than a checkbox they tab past. + +const ACK_TEXT = [ + 'Forum posts and announcements are visible only to a Team’s members. This site cannot see who can' + + ' read a channel on another platform, so it cannot check that for you.', + 'By enabling these events you confirm that the destination channel is restricted to the members of' + + ' the Team whose posts it will carry.', +] + +export default function TeamIntegrations() { + const [config, setConfig] = useState(null) + const [teams, setTeams] = useState([]) + const [draft, setDraft] = useState(null) + const [dialog, setDialog] = useState(null) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + setError('') + try { + const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()]) + setConfig(cfg) + setTeams((teamList.teams || []).filter((t) => t.status === 'active')) + } catch (err) { + // A moderator never reaches this panel — the admin nav does not render it — + // so a 403 here means the role changed underneath an open tab rather than a + // routing mistake, and saying so beats "could not load". + setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.')) + } + }, []) + + useEffect(() => { load() }, [load]) + + if (!config) { + return ( +
+

Notification bridge

+ {error &&

{error}

} +
+ ) + } + + const membersOnlyIds = membersOnlyIdsOf(config.events) + const { hasDefault, teams: available } = availableTargets(config.rows, teams) + + async function persist(next) { + setBusy(true) + setError('') + setNotice('') + try { + await api.admin.saveTeamIntegration({ + teamId: next.teamId, + events: next.events, + channelRef: next.channelRef.trim() || null, + enabled: next.enabled, + membersAck: next.membersAck, + }) + setDraft(null) + setDialog(null) + setNotice('Saved.') + await load() + } catch (err) { + setError(err.message || 'Could not save.') + setDialog(null) + } finally { + setBusy(false) + } + } + + // Enabling members-only events without a standing acknowledgement asks first. + // Everything else — disabling, editing a channel, adding a roster event — saves + // straight through. + function save() { + if (!draft) return + if (needsAcknowledgement(draft, membersOnlyIds)) { + setDialog(draft) + return + } + persist(draft) + } + + async function remove(row) { + setBusy(true) + setError('') + try { + await api.admin.deleteTeamIntegration(row.team_id ?? null) + setNotice('Removed.') + await load() + } catch (err) { + setError(err.message || 'Could not remove.') + } finally { + setBusy(false) + } + } + + return ( +
+

Notification bridge

+

+ Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and + override it for individual Teams. A message is sent once and not retried — the bridge is a + courtesy, and nothing on the site depends on it arriving. +

+ + {error &&

{error}

} + {notice &&

{notice}

} + + {config.rows.length === 0 && !draft && ( +

Nothing configured — no Team events leave the site.

+ )} + + {config.rows.length > 0 && ( + + + + + + {config.rows.map((row) => ( + + + + + + + + ))} + +
Applies toEventsChannelState
+ {appliesToLabel(row)} + {isDefaultRow(row) && (default)} + + {row.events.length === 0 + ? none + : row.events.map(eventLabel).join(', ')} + {row.channel_ref || unset} + {row.enabled ? 'Enabled' : 'Disabled'} + {row.members_ack && ( + + members-only destination confirmed + {row.members_ack_username ? ` by ${row.members_ack_username}` : ''} + + )} + + + +
+ )} + + {!draft && ( +
+ {!hasDefault && ( + + )} + {available.length > 0 && ( + + )} +
+ )} + + {draft && ( +
+ + + Events to send + {config.events.map((event) => ( + + ))} + + + + + + {draft.membersAck && ( +

+ You have confirmed this channel is restricted to the Team’s members.{' '} + +

+ )} + +
+ + +
+
+ )} + + {dialog && ( +
+

Confirm the destination’s audience

+ {ACK_TEXT.map((line) => ( +

{line}

+ ))} + + +
+ )} +
+ ) +} diff --git a/client/src/routes/admin/views/TeamsAdmin.jsx b/client/src/routes/admin/views/TeamsAdmin.jsx index e1f2522..4c412c5 100644 --- a/client/src/routes/admin/views/TeamsAdmin.jsx +++ b/client/src/routes/admin/views/TeamsAdmin.jsx @@ -6,6 +6,7 @@ import { } from '../../../lib/teamAdmin.js' import { useAuth } from '../../../contexts/AuthContext.jsx' import { api } from '../../../api/client.js' +import TeamIntegrations from './TeamIntegrations.jsx' // Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9). // @@ -341,6 +342,11 @@ export default function TeamsAdmin() { {ledgerTeam && setLedgerTeam(null)} />} + {/* Admin-only, matching the server (§7.2). Rendered for a moderator it would + be a panel every action in fails 403 — the role gate is the server's, and + this is only how the screen agrees with it. */} + {role === 'admin' && } + diff --git a/client/test/teamIntegrations.test.js b/client/test/teamIntegrations.test.js new file mode 100644 index 0000000..99260c7 --- /dev/null +++ b/client/test/teamIntegrations.test.js @@ -0,0 +1,129 @@ +// What Admin → Teams → Notification bridge decides (client/src/lib/teamIntegrations.js). +// +// The test that earns this file: **repointing a row must not carry its +// acknowledgement across.** That is the one way this screen could actively +// mislead — an operator confirms a private channel, changes the id to a public +// one, and the form still shows the confirmation as standing. The server clears +// it either way, so the failure would be a screen that disagrees with the answer +// it is about to get, which is worse than one that simply refuses. +// +// The rest is the boundary of the confirmation dialog: it must open when it +// matters and stay shut when it does not, because a dialog that appears on saves +// that did not need it is one people learn to click through. +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent, + setChannel, carriesMembersOnly, needsAcknowledgement, membersOnlyIdsOf, availableTargets, +} from '../src/lib/teamIntegrations.js' + +const MEMBERS_ONLY = ['team.forum.post', 'team.announcement'] +const ROSTER = 'team.member.joined' +const FORUM = 'team.forum.post' + +const draft = (over = {}) => ({ ...blankDraft(null), ...over }) + +// ── The acknowledgement dies with its channel ────────────────────────────── + +test('changing the channel drops a standing acknowledgement', () => { + const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true }) + const after = setChannel(before, '222') + assert.equal(after.membersAck, false) + assert.equal(after.channelRef, '222') +}) + +test('setting the SAME channel does not clear it — an unrelated re-render is not a repoint', () => { + const before = draft({ channelRef: '111', membersAck: true }) + const after = setChannel(before, '111') + assert.equal(after.membersAck, true) + assert.equal(after, before, 'and the object is returned unchanged, so nothing re-renders') +}) + +test('a repointed row needs the dialog again, which is the whole point of clearing it', () => { + const before = draft({ channelRef: '111', membersAck: true, events: [FORUM], enabled: true }) + assert.equal(needsAcknowledgement(before, MEMBERS_ONLY), false) + assert.equal(needsAcknowledgement(setChannel(before, '222'), MEMBERS_ONLY), true) +}) + +// ── When the dialog opens ────────────────────────────────────────────────── + +test('enabling a forum event without the tick asks first', () => { + assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), MEMBERS_ONLY), true) +}) + +test('a DISABLED draft carrying forum events does not ask — nothing is being published yet', () => { + assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: false }), MEMBERS_ONLY), false) +}) + +test('a roster-only bridge never asks, however it is configured', () => { + assert.equal(needsAcknowledgement(draft({ events: [ROSTER], enabled: true }), MEMBERS_ONLY), false) + assert.equal(carriesMembersOnly(draft({ events: [ROSTER] }), MEMBERS_ONLY), false) +}) + +test('an acknowledgement already given means no second dialog for an unrelated edit', () => { + const d = draft({ events: [FORUM], enabled: true, membersAck: true, channelRef: '111' }) + const withRoster = toggleEvent(d, ROSTER) + assert.equal(needsAcknowledgement(withRoster, MEMBERS_ONLY), false) +}) + +test('the members-only set comes from the server, not from a list held here', () => { + // The client must not decide what is members-only: a future stream added + // server-side would silently escape a hardcoded client list. + assert.deepEqual( + membersOnlyIdsOf([{ id: ROSTER, membersOnly: false }, { id: FORUM, membersOnly: true }]), + [FORUM], + ) + // Told nothing is members-only, the dialog never opens — the server is the one + // that would then refuse, which is the correct division. + assert.equal(needsAcknowledgement(draft({ events: [FORUM], enabled: true }), []), false) +}) + +// ── Events, rows and targets ─────────────────────────────────────────────── + +test('toggling adds then removes, and preserves selection order', () => { + let d = draft() + d = toggleEvent(d, FORUM) + d = toggleEvent(d, ROSTER) + assert.deepEqual(d.events, [FORUM, ROSTER]) + d = toggleEvent(d, FORUM) + assert.deepEqual(d.events, [ROSTER]) +}) + +test('the default row is identified by a NULL team, and an undefined one counts too', () => { + assert.equal(isDefaultRow({ team_id: null }), true) + assert.equal(isDefaultRow({}), true) + assert.equal(isDefaultRow({ team_id: 4 }), false) + assert.equal(rowKey({ team_id: null }), 'default') + assert.equal(rowKey({ team_id: 4 }), '4') +}) + +test('a row is labelled by the staff override first, then the name, then its id', () => { + assert.equal(appliesToLabel({ team_id: null }), 'All Teams') + assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real', display_name_override: 'Shown' }), 'Shown') + assert.equal(appliesToLabel({ team_id: 4, team_name: 'Real' }), 'Real') + assert.equal(appliesToLabel({ team_id: 4 }), 'Team #4') +}) + +test('a Team that already has an override is not offered a second one', () => { + const rows = [{ team_id: null }, { team_id: 2 }] + const teams = [{ id: 1, status: 'active' }, { id: 2, status: 'active' }, { id: 3, status: 'archived' }] + const { hasDefault, teams: available } = availableTargets(rows, teams) + assert.equal(hasDefault, true) + assert.deepEqual(available.map((t) => t.id), [1], 'the taken one and the archived one are both out') +}) + +test('with no default configured, the default is still offered', () => { + const { hasDefault } = availableTargets([{ team_id: 2 }], []) + assert.equal(hasDefault, false) +}) + +test('a row round-trips through the draft without changing what it means', () => { + const row = { team_id: 4, events: [FORUM], channel_ref: '111', enabled: 1, members_ack: 1 } + assert.deepEqual(draftFrom(row), { teamId: 4, events: [FORUM], channelRef: '111', enabled: true, membersAck: true }) +}) + +test('an unknown event id renders as itself rather than as blank', () => { + assert.equal(eventLabel(FORUM), 'New forum post') + assert.equal(eventLabel('team.something.new'), 'team.something.new') +}) diff --git a/server/db/schema.sql b/server/db/schema.sql index a1ba538..8f3e826 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1305,6 +1305,54 @@ CREATE TABLE IF NOT EXISTS team_notification_prefs ( INDEX idx_tnp_digest (email_mode, last_digest_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- ── The integration bridge's configuration (TEAMS.md §7.2, phase 8) ───────── +-- +-- The SAME events as §6, delivered to a second consumer. Not a second pipeline: +-- `utils/teamNotify.js` computes the recipient set once and hands the event to +-- push, to email and now to this bridge. +-- +-- `team_id NULL` is the deployment-wide default and a per-Team row overrides it, +-- which is what §7.2 asks for — but its `PRIMARY KEY (platform, team_id)` cannot +-- express it: MariaDB coerces every PRIMARY KEY column to NOT NULL, so the +-- default row is unrepresentable and the whole override mechanism has no base +-- case. Hence the surrogate key plus a generated `team_key`, the same trick +-- `teams.active_key` and `content_reports.open_marker` use: IFNULL folds the +-- default row onto 0, which no `teams.id` can be, so one default and one row per +-- Team coexist under a single UNIQUE key. It also buys the foreign key the +-- original DDL had no room for — without it, deleting a Team leaves its bridge +-- config behind to be inherited by the next Team that lands on the id. +-- +-- **`members_ack` is a precondition, not a preference.** Forum posts and +-- announcements are members-only ALWAYS — there is no public forum thread, and +-- §7.2's gate ("visibility is public, or the channel is configured for a +-- members-only context") has no data source on either side: the streams carry no +-- visibility and core cannot see a Discord channel's permissions. Only the +-- operator can. So enabling a members-only event requires an explicit, attributed +-- acknowledgement that the destination is restricted to that Team, recorded the +-- way `teams_forum_uploads_ack` records the image-policy one. Changing the channel +-- CLEARS it (see the model): an acknowledgement is about a destination, and it +-- cannot survive the destination changing underneath it. +CREATE TABLE IF NOT EXISTS team_integration_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, phase 10 makes it a registry key + team_id INT NULL, -- NULL = the deployment-wide default + events JSON NOT NULL, -- ['team.announcement','team.forum.post'] + channel_ref VARCHAR(64) NULL, -- destination on that platform, opaque to core + enabled TINYINT(1) NOT NULL DEFAULT 0, + -- The §7.2 gate, as an operator assertion with a name against it. + members_ack TINYINT(1) NOT NULL DEFAULT 0, + members_ack_by INT NULL, + members_ack_at DATETIME NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + team_key INT AS (IFNULL(team_id, 0)) STORED, + UNIQUE KEY uq_tic_platform_team (platform, team_key), + CONSTRAINT fk_tic_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + -- SET NULL rather than CASCADE, for the same reason every other snapshot in + -- this file is: deleting the admin's account must not silently un-acknowledge a + -- policy and start withholding messages the deployment is configured to send. + CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. diff --git a/server/routes.guards.json b/server/routes.guards.json index e2f624d..baffc1d 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -880,6 +880,37 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/admin/teams/integrations", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/teams/integrations", + "handlers": 8, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/teams/integrations/:teamId", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, { "method": "GET", "path": "/api/v1/admin/teams/requests", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index b18ddb8..cdfbc8d 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -349,6 +349,18 @@ "method": "GET", "path": "/api/v1/admin/teams/forum/uploads" }, + { + "method": "GET", + "path": "/api/v1/admin/teams/integrations" + }, + { + "method": "PUT", + "path": "/api/v1/admin/teams/integrations" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/teams/integrations/:teamId" + }, { "method": "GET", "path": "/api/v1/admin/teams/requests" diff --git a/server/src/model/teams/teamIntegration.db.js b/server/src/model/teams/teamIntegration.db.js new file mode 100644 index 0000000..7e63693 --- /dev/null +++ b/server/src/model/teams/teamIntegration.db.js @@ -0,0 +1,122 @@ +// SQL for the integration bridge's configuration (TEAMS.md §7.2, phase 8). +// +// One table, and almost all of its subtlety is in the schema comment rather than +// here: `team_key` is a generated `IFNULL(team_id, 0)`, so the deployment-wide +// default and the per-Team overrides live under one UNIQUE key without the +// default row needing a NULL in a primary key it cannot have. +// +// **Reads join `teams` and callers get the Team's name.** Not for display alone: +// the resolver's answer is the input to a message that names a Team, and a second +// round trip per notification to fetch a name the first query already walked past +// is the kind of thing that only shows up under a busy forum. + +const { query } = require('../../utils/db') + +const COLUMNS = ` + c.id, c.platform, c.team_id, c.events, c.channel_ref, c.enabled, + c.members_ack, c.members_ack_by, c.members_ack_at, c.updated_at` + +/** + * Every row for a platform — the default first, then the overrides by Team name. + * + * The admin panel's whole listing, in one query. `team_name` is NULL on exactly + * one row (the default), which is also how the client tells them apart without + * needing to reason about `team_id`. + */ +async function listForPlatform(platform) { + return query( + `SELECT ${COLUMNS}, t.name AS team_name, t.slug AS team_slug, t.display_name_override, + u.username AS members_ack_username + FROM team_integration_config c + LEFT JOIN teams t ON t.id = c.team_id + LEFT JOIN users u ON u.id = c.members_ack_by + WHERE c.platform = ? + ORDER BY c.team_id IS NOT NULL, COALESCE(t.name, '')`, + [platform], + ) +} + +/** + * The row that governs `teamId`, or null. + * + * `team_key` is what makes this one query rather than two: asking for the pair + * (0, teamId) returns the default and the override together, and `ORDER BY + * team_key DESC LIMIT 1` puts the override first when it exists. A caller that + * fetched the default and then looked for an override would do two round trips + * per notification for an answer the index already holds. + */ +async function resolveFor(platform, teamId) { + const rows = await query( + `SELECT ${COLUMNS}, t.name AS team_name, t.display_name_override + FROM team_integration_config c + LEFT JOIN teams t ON t.id = c.team_id + WHERE c.platform = ? AND c.team_key IN (0, ?) + ORDER BY c.team_key DESC + LIMIT 1`, + [platform, Number(teamId)], + ) + return rows[0] || null +} + +async function getById(id) { + const rows = await query( + `SELECT ${COLUMNS}, t.name AS team_name FROM team_integration_config c + LEFT JOIN teams t ON t.id = c.team_id + WHERE c.id = ? LIMIT 1`, + [Number(id)], + ) + return rows[0] || null +} + +async function getForTeam(platform, teamId) { + const rows = await query( + `SELECT ${COLUMNS} FROM team_integration_config c + WHERE c.platform = ? AND c.team_key = ? LIMIT 1`, + [platform, teamId === null || teamId === undefined ? 0 : Number(teamId)], + ) + return rows[0] || null +} + +/** + * Create or replace the row for (platform, team). + * + * A full replace rather than a patch, and the acknowledgement columns are part of + * what is replaced — the model decides what they should be, because "did the + * channel change" is a comparison against the row that is about to be overwritten + * and only the model has both halves. + */ +async function upsert({ platform, teamId, events, channelRef, enabled, membersAck, membersAckBy, membersAckAt }) { + await query( + `INSERT INTO team_integration_config + (platform, team_id, events, channel_ref, enabled, members_ack, members_ack_by, members_ack_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + events = VALUES(events), + channel_ref = VALUES(channel_ref), + enabled = VALUES(enabled), + members_ack = VALUES(members_ack), + members_ack_by = VALUES(members_ack_by), + members_ack_at = VALUES(members_ack_at)`, + [ + platform, + teamId === null || teamId === undefined ? null : Number(teamId), + JSON.stringify(events || []), + channelRef || null, + enabled ? 1 : 0, + membersAck ? 1 : 0, + membersAckBy || null, + membersAckAt || null, + ], + ) + return getForTeam(platform, teamId) +} + +async function remove(platform, teamId) { + const res = await query('DELETE FROM team_integration_config WHERE platform = ? AND team_key = ?', [ + platform, + teamId === null || teamId === undefined ? 0 : Number(teamId), + ]) + return Number(res && res.affectedRows) || 0 +} + +module.exports = { listForPlatform, resolveFor, getById, getForTeam, upsert, remove } diff --git a/server/src/model/teams/teamIntegration.model.js b/server/src/model/teams/teamIntegration.model.js new file mode 100644 index 0000000..2955b22 --- /dev/null +++ b/server/src/model/teams/teamIntegration.model.js @@ -0,0 +1,272 @@ +// ── The integration bridge's configuration and its one precondition ──────── +// +// TEAMS.md §7.2, phase 8. An operator says "send these Team events to this +// Discord channel", globally or for one Team, and this file is where that +// sentence is validated, stored and resolved. +// +// **Two of the four streams can never be public, and that is the whole reason +// this file is more than a settings row.** §7.2 gates bridging on "the event's +// visibility is public, or the destination channel is configured for a +// members-only Team context". Neither half exists in the tree and neither can: +// the four `team.*` streams carry no visibility (only `team_activity` rows do, +// and a notification is not an activity row), forum threads have no public/ +// members column because a forum is members-only by construction — everything in +// it sits behind `team_forum_grants` — and core cannot see a Discord channel's +// permissions to know what it is. +// +// Only the operator can see that. So the gate becomes an ATTRIBUTED +// ACKNOWLEDGEMENT: enabling a members-only event requires an explicit tick that +// the destination is restricted to that Team's members, recorded with who gave it +// and when, in the same shape `teams_forum_uploads_ack` records the image-policy +// one. It is a precondition, not a preference — `assertEnableable` refuses the +// save rather than quietly dropping the event at delivery time, because a config +// that silently does less than it says is worse than one that will not save. +// +// **Changing the channel clears the acknowledgement.** An acknowledgement is +// about a destination; it cannot survive the destination changing underneath it, +// or an operator would tick "this channel is private", then repoint the row at a +// public one and keep the permission they were granted for a different place. +// +// **Every read fails closed**, like `teamForumSettings`: a DB fault reports no +// bridge configured, because the cost of failing closed is a Discord channel that +// stays quiet for a minute and the cost of failing open is members-only text in a +// room the operator never approved. + +const db = require('./teamIntegration.db') +const log = require('../../utils/logger')('team-integration') + +// The only platform phase 8 knows. Deliberately a value rather than a hardcoded +// literal at every call site: phase 10 turns this into a lookup against the +// declared-capability registry, and the fewer places that spell 'discord' the +// smaller that change is. +const DISCORD = 'discord' +const PLATFORMS = [DISCORD] + +// The four §6.2 streams, and which of them can reach a channel core cannot vet. +// +// A stream is members-only if the CONTENT behind it is: `team.forum.post` and +// `team.announcement` both name a thread nobody outside the Team may read. The +// roster pair is public — Team pages and rosters are public by §1's projection +// rules — so bridging those asserts nothing and needs no tick. +const BRIDGEABLE = [ + 'team.member.joined', + 'team.leadership.changed', + 'team.forum.post', + 'team.announcement', +] + +const MEMBERS_ONLY = new Set(['team.forum.post', 'team.announcement']) + +// Discord snowflakes are 17-20 digits today and the format is not promised. The +// check is only that a channel ref is plausibly one and cannot smuggle anything — +// core treats it as opaque and the bot is what resolves it. +const CHANNEL_RE = /^[0-9]{5,32}$/ + +const isMembersOnly = (streamId) => MEMBERS_ONLY.has(streamId) + +/** Does this event list contain anything that would publish members-only text? */ +const needsAck = (events) => (events || []).some(isMembersOnly) + +/** + * Normalise an operator-supplied event list. + * + * Unknown ids are REJECTED rather than dropped. A silently-dropped event is a + * config screen that shows you saved something you did not, and the set is small + * and fixed enough that a typo is a mistake worth reporting. + */ +function normaliseEvents(events) { + if (!Array.isArray(events)) { + const err = new Error('events must be an array') + err.status = 400 + throw err + } + const seen = [] + for (const raw of events) { + const id = String(raw || '').trim() + if (!BRIDGEABLE.includes(id)) { + const err = new Error(`unknown event: ${id}`) + err.status = 400 + throw err + } + if (!seen.includes(id)) seen.push(id) + } + return seen +} + +function normaliseChannel(channelRef) { + const value = String(channelRef || '').trim() + if (!value) return null + if (!CHANNEL_RE.test(value)) { + const err = new Error('channel must be a numeric channel id') + err.status = 400 + throw err + } + return value +} + +/** + * The gate, as a throw. + * + * Order matters to the message an operator reads: an enabled row with no channel + * is a different mistake from one with an unacknowledged channel, and reporting + * the second when the first is true would send them to tick a box that would not + * have helped. + */ +function assertEnableable({ enabled, events, channelRef, membersAck }) { + if (!enabled) return + if (!channelRef) { + const err = new Error('a destination channel is required to enable this bridge') + err.status = 422 + throw err + } + if (events.length === 0) { + const err = new Error('at least one event is required to enable this bridge') + err.status = 422 + throw err + } + if (needsAck(events) && !membersAck) { + const err = new Error( + 'forum posts and announcements are visible only to a Team’s members — confirm the destination channel is restricted to them before enabling', + ) + err.status = 422 + err.code = 'members_ack_required' + throw err + } +} + +/** Rows for the admin panel, `events` already parsed. */ +async function list(platform = DISCORD) { + const rows = await db.listForPlatform(platform) + return rows.map(shape) +} + +/** + * Parse the stored JSON once, here. + * + * `mariadb` hands a JSON column back as a string on some server versions and as a + * parsed value on others, which is a difference nobody wants to rediscover in a + * controller. Anything unreadable becomes an empty list rather than a throw: a + * row with a corrupt event list should render as a row that bridges nothing, not + * take the whole admin page down. + */ +function shape(row) { + if (!row) return null + let events = row.events + if (typeof events === 'string') { + try { + events = JSON.parse(events) + } catch { + events = [] + } + } + return { ...row, events: Array.isArray(events) ? events : [], enabled: !!row.enabled, members_ack: !!row.members_ack } +} + +/** + * The row that governs `teamId` — the override if there is one, otherwise the + * deployment default — filtered down to what may actually be delivered. + * + * **The acknowledgement is checked HERE as well as at the save.** A row saved + * with the tick can lose it later: an admin repoints the channel, or a future + * change to what counts as members-only reclassifies a stream a row already + * carries. Re-asking at delivery is what makes the tick a live property of the + * row rather than a note about a save that happened once. + */ +async function resolve(teamId, platform = DISCORD) { + try { + const row = shape(await db.resolveFor(platform, teamId)) + if (!row || !row.enabled || !row.channel_ref) return null + const events = row.events.filter((id) => (isMembersOnly(id) ? row.members_ack : true)) + if (events.length === 0) return null + return { ...row, events } + } catch (err) { + log.warn('bridge config lookup failed — treating as unconfigured', { + teamId, + platform, + message: err.message, + }) + return null + } +} + +/** Is `streamId` bridged for this Team? The delivery path's whole question. */ +async function destinationFor(teamId, streamId, platform = DISCORD) { + const row = await resolve(teamId, platform) + if (!row || !row.events.includes(streamId)) return null + return { channelRef: row.channel_ref, membersOnly: isMembersOnly(streamId), platform } +} + +/** + * Create or replace the row for (platform, team). + * + * `actorId` is the admin doing the saving, and it is what lands in + * `members_ack_by` — the acknowledgement names a person, so it cannot be written + * by a path that does not know who they are. + */ +async function save({ platform = DISCORD, teamId = null, events, channelRef, enabled, membersAck }, actorId) { + if (!PLATFORMS.includes(platform)) { + const err = new Error(`unknown platform: ${platform}`) + err.status = 400 + throw err + } + + const nextEvents = normaliseEvents(events) + const nextChannel = normaliseChannel(channelRef) + const existing = shape(await db.getForTeam(platform, teamId)) + + // An acknowledgement survives an ordinary edit and dies with the channel it was + // given for. `membersAck === false` from the client is an explicit withdrawal + // and is honoured; `undefined` means "leave it", which is what a save that only + // toggled an event should do. + const channelChanged = !!existing && existing.channel_ref !== nextChannel + let ack = existing ? existing.members_ack : false + if (membersAck === false) ack = false + else if (membersAck === true) ack = true + if (channelChanged) ack = membersAck === true + + const nextEnabled = !!enabled + assertEnableable({ enabled: nextEnabled, events: nextEvents, channelRef: nextChannel, membersAck: ack }) + + // Re-stamp only when the acknowledgement is newly given, so an unrelated save + // does not rewrite the date on a decision nobody revisited. + // + // `channelChanged` belongs in this condition and it is easy to leave out: an + // acknowledgement given alongside a NEW channel is a new acknowledgement even + // though the column was already 1, and without it the row keeps naming whoever + // vetted the PREVIOUS destination. That attribution is the whole audit value of + // the column — it has to name the person who looked at the channel the row now + // points at. + const freshlyAcked = ack && (channelChanged || !(existing && existing.members_ack)) + const row = await db.upsert({ + platform, + teamId, + events: nextEvents, + channelRef: nextChannel, + enabled: nextEnabled, + membersAck: ack, + membersAckBy: ack ? (freshlyAcked ? actorId : existing.members_ack_by) : null, + membersAckAt: ack ? (freshlyAcked ? new Date() : existing.members_ack_at) : null, + }) + return shape(row) +} + +async function remove(platform, teamId) { + return db.remove(platform, teamId) +} + +module.exports = { + DISCORD, + PLATFORMS, + BRIDGEABLE, + MEMBERS_ONLY, + isMembersOnly, + needsAck, + normaliseEvents, + normaliseChannel, + assertEnableable, + list, + resolve, + destinationFor, + save, + remove, +} diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js index 395dbaa..f875b61 100644 --- a/server/src/model/teams/teamSync.model.js +++ b/server/src/model/teams/teamSync.model.js @@ -213,7 +213,10 @@ async function logRosterActivity(team, { joined, left, promoted, demoted }) { async function notifyRoster(team, { joined, promoted, demoted }) { if (!team.roster_synced_at) return try { - if (joined.length > 0) await teamNotify.memberJoined(team) + // The count rides along for the Discord bridge (§7.2), which has no app on + // the other end to pull the roster after a content-free nudge. The tickle + // itself is unchanged and still carries nothing. + if (joined.length > 0) await teamNotify.memberJoined(team, { count: joined.length }) 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 }) diff --git a/server/src/router/v1/admin/teams.controller.js b/server/src/router/v1/admin/teams.controller.js index a9868d5..865d310 100644 --- a/server/src/router/v1/admin/teams.controller.js +++ b/server/src/router/v1/admin/teams.controller.js @@ -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, diff --git a/server/src/router/v1/admin/teams.router.js b/server/src/router/v1/admin/teams.router.js index dde13af..dade2a8 100644 --- a/server/src/router/v1/admin/teams.router.js +++ b/server/src/router/v1/admin/teams.router.js @@ -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'] diff --git a/server/src/utils/botInternalClient.js b/server/src/utils/botInternalClient.js index 63967fc..92d3a3f 100644 --- a/server/src/utils/botInternalClient.js +++ b/server/src/utils/botInternalClient.js @@ -84,4 +84,21 @@ function refreshCommands() { return call('/internal/refresh-commands', { method: 'POST', body: {} }) } -module.exports = { pushConfig, getStatus, announce, reverseModAction, refreshCommands } +// Site -> bot: a Team notification the operator has configured a channel for +// (TEAMS.md §7.2). Best-effort and one-shot, unlike `announce`: a news post is a +// durable artifact whose Discord copy is expected to exist, so it rides the +// announce_jobs retry; a Team notification is the moment it describes, and a +// message that lands twenty minutes late is worse than one that never lands. +// +// The channel is chosen by the SITE and passed in, not looked up by the bot from +// guild_config the way `announce` finds #news. Which channel a Team's events go +// to is per-Team configuration that lives in team_integration_config, and a bot +// that resolved it would need a second copy of that table. +function teamNotify({ channelId, streamId, teamName, teamUrl, title, body, url }) { + return call('/internal/team-notify', { + method: 'POST', + body: { channel_id: channelId, stream: streamId, team_name: teamName, team_url: teamUrl, title, body, url }, + }) +} + +module.exports = { pushConfig, getStatus, announce, reverseModAction, refreshCommands, teamNotify } diff --git a/server/src/utils/teamBridge.js b/server/src/utils/teamBridge.js new file mode 100644 index 0000000..f97ec03 --- /dev/null +++ b/server/src/utils/teamBridge.js @@ -0,0 +1,120 @@ +// ── The integration bridge: the same Team event, a second delivery ───────── +// +// TEAMS.md §7.2, phase 8. §6 gave a Team event two sinks — a content-free push +// tickle and, for forum content, an email. This is the third, and it is +// deliberately NOT a second pipeline: `teamNotify.js` computes the recipient set +// once, and the event it already has in hand is handed here on the way out. +// +// **A Discord message carries content; a push tickle does not**, and the two look +// like the same event only from far away. ntfy is an untrusted relay reached by an +// unguessable topic, so the tickle is content-free and the app pulls the real +// thing over the authenticated API. A Discord channel is an operator-configured, +// trusted destination where "something happened, go look" would be useless — and, +// crucially, there is no app on the other end to do the pulling. So core composes +// the text here. +// +// **Composing that text is core's to do, unlike an activity summary.** §4.1 forbids +// core phrasing a `team_activity` line because the vocabulary is the module's. This +// is the opposite case: these are core's own four notification streams, about core's +// own forum and core's own membership projection, and core already composes the +// email body for exactly the same events (§6.4). Nothing here names a game concept. +// +// **Nothing in this file throws.** Same contract as the file that calls it: the +// forum reply is written and answered before any of this runs, and a courtesy that +// can fail the transaction behind it is a defect. +// +// **One-shot, not queued.** `announce` earns its retry/backoff because a news post +// is a durable artifact whose Discord copy is expected to exist; a Team +// notification is the moment it describes. A message that arrives twenty minutes +// after the conversation has moved on is worse than one that never arrives, and a +// second job table plus a second worker is a lot of machinery to buy that. A bot +// that is down drops the message and the site is unaffected — which is the same +// deal the push tickle takes. + +const botInternalClient = require('./botInternalClient') +const teamIntegration = require('../model/teams/teamIntegration.model') +const log = require('./logger')('team-bridge') + +// How much of a post body a Discord embed carries. Longer than the email's 200 — +// an embed description holds 4096 characters and a channel is a place people skim +// — but still an excerpt, because the point is to get someone to open the thread. +const EXCERPT_CHARS = 400 + +/** + * Deliver one event, if this Team's configuration asks for it. + * + * The access decision is `destinationFor`'s and it has already re-checked the + * members-only acknowledgement against the live row, so by the time anything is + * composed here the operator has said this channel may hold it. + * + * @returns {Promise} whether a message was handed to the bot. False is + * the ordinary answer on a deployment with no bridge configured, which is most + * of them — it is not an error and is not logged as one. + */ +async function deliver(streamId, team, content = {}) { + try { + if (!team || !team.id) return false + const destination = await teamIntegration.destinationFor(team.id, streamId) + if (!destination) return false + + const res = await botInternalClient.teamNotify({ + channelId: destination.channelRef, + streamId, + teamName: teamLabel(team), + teamUrl: content.teamUrl || null, + title: content.title || null, + body: content.body || null, + url: content.url || null, + }) + + if (!res || !res.ok) { + // Warn, not error, and then stop. There is nothing to retry against and + // nothing downstream that needs to know: the push and email sinks have + // already run and neither depends on this one. + log.warn('bridge delivery failed', { + teamId: team.id, + streamId, + status: res && res.status, + error: res && res.error, + }) + return false + } + return true + } catch (err) { + log.warn('bridge delivery threw', { teamId: team && team.id, streamId, message: err.message }) + return false + } +} + +const teamLabel = (team) => (team && (team.display_name_override || team.name)) || 'a team' + +/** Markup out, whitespace collapsed, truncated — the embed description is text. */ +function excerpt(html) { + const text = String(html || '') + .replace(/<[^>]*>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/\s+/g, ' ') + .trim() + return text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text +} + +/** + * "3 new members joined." — a count, and never a name. + * + * The roster sync notifies once per run rather than once per member (§6.2), so a + * count is all the caller has; it is also all this should say. A member's + * character name is game-sourced text that has been through core's reserved-name + * screening for a PAGE, not for a channel, and the roster it comes from is on a + * public page anybody in that channel can already open. + */ +function memberJoinedBody(count) { + const n = Number(count) || 0 + if (n <= 0) return 'The roster has changed.' + return n === 1 ? 'A new member joined.' : `${n} new members joined.` +} + +module.exports = { deliver, excerpt, teamLabel, memberJoinedBody, EXCERPT_CHARS } diff --git a/server/src/utils/teamNotify.js b/server/src/utils/teamNotify.js index 9176bc3..e4294e5 100644 --- a/server/src/utils/teamNotify.js +++ b/server/src/utils/teamNotify.js @@ -17,6 +17,14 @@ // is a destination the recipient chose rather than a relay (§6.4). The asymmetry // is the security model, not an inconsistency to tidy up. // +// **Phase 8 added a THIRD sink, and it is a second delivery rather than a second +// pipeline.** `utils/teamBridge.js` takes the same event, already computed, and +// hands it to a Discord channel the operator configured — which is why every +// entry point below calls it beside the tickle instead of anything re-deriving +// the event. Note that the bridge does NOT take the recipient set: its audience +// is whoever can read a channel, which is why enabling it for members-only +// content needs an operator acknowledgement (§7.2, teamIntegration.model.js). +// // **Roster events are push-only, and forum events are the only ones that email.** // §6.4's argument for the email sink is the web-only user who never learns that // someone replied to their own thread. "Someone joined the guild" is not that: it @@ -26,6 +34,7 @@ // the file that says so. const pushDispatch = require('./pushDispatch') +const teamBridge = require('./teamBridge') const teamNotify = require('../model/teams/teamNotify.model') const forumSettings = require('../model/teams/teamForumSettings.model') const mailer = require('./mailer') @@ -119,9 +128,21 @@ async function tickle(streamId, team, { ref, exclude = [] } = {}) { // No `memberName` argument, and that is the point: a tickle is content-free, so // there is nothing about WHO joined for this function to carry. The name is on // the activity feed the app pulls after waking. -async function memberJoined(team) { +// +// `count` is phase 8's one addition and it is for the BRIDGE, not the tickle: a +// Discord channel has no app on the other end to pull anything, so the message +// has to say something, and "3 new members joined" is the most a caller that +// notifies once per sweep can honestly say. Optional, so the sync is the only +// caller that has to know it exists. +async function memberJoined(team, { count } = {}) { try { - return await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` }) + const sent = await tickle(STREAMS.MEMBER_JOINED, team, { ref: `team:${team.id}` }) + await teamBridge.deliver(STREAMS.MEMBER_JOINED, team, { + body: teamBridge.memberJoinedBody(count), + teamUrl: teamPageUrl(team), + url: teamPageUrl(team), + }) + return sent } catch (err) { log.warn('member-joined notification failed', { teamId: team && team.id, message: err.message }) return 0 @@ -130,7 +151,13 @@ async function memberJoined(team) { async function leadershipChanged(team) { try { - return await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` }) + const sent = await tickle(STREAMS.LEADERSHIP_CHANGED, team, { ref: `team:${team.id}` }) + await teamBridge.deliver(STREAMS.LEADERSHIP_CHANGED, team, { + body: 'Leadership has changed.', + teamUrl: teamPageUrl(team), + url: teamPageUrl(team), + }) + return sent } catch (err) { log.warn('leadership notification failed', { teamId: team && team.id, message: err.message }) return 0 @@ -156,16 +183,26 @@ async function forumPost({ team, threadId, threadTitle, type, authorUserId, auth // digest worker has no route in front of it, so the check has to live here as // well as there — and a switch flipped between a write and its notification // must silence the notification. - if (!(await forumSettings.forumsEnabled())) return { push: 0, emails: 0 } + if (!(await forumSettings.forumsEnabled())) return { push: 0, emails: 0, bridged: false } const stream = type === 'announcement' ? STREAMS.ANNOUNCEMENT : STREAMS.FORUM_POST const exclude = authorUserId ? [authorUserId] : [] const push = await tickle(stream, team, { ref: `team:${team.id}:thread:${threadId}`, exclude }) const emails = await emailImmediate({ team, threadId, threadTitle, type, exclude, authorName, bodyHtml }) - return { push, emails } + // The bridge is NOT given `exclude`. Excluding the author is a property of a + // per-recipient sink — nobody wants their own post mailed back to them — and a + // channel has no per-recipient anything. Suppressing the message because the + // author happens to be in the channel would deprive everyone else in it. + const bridged = await teamBridge.deliver(stream, team, { + title: threadTitle, + body: teamBridge.excerpt(bodyHtml), + url: threadUrl(team, threadId), + teamUrl: teamPageUrl(team), + }) + return { push, emails, bridged } } catch (err) { log.warn('forum notification failed', { teamId: team && team.id, message: err.message }) - return { push: 0, emails: 0 } + return { push: 0, emails: 0, bridged: false } } } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 5cdc2dd..727b1f1 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -4604,6 +4604,174 @@ ] } }, + "/api/v1/admin/teams/integrations": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "The Team notification bridge’s configuration (admin only)", + "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.", + "responses": { + "200": { + "description": "Bridge configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamIntegrationConfig" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Teams" + ], + "summary": "Create or replace one bridge destination (admin only)", + "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.", + "responses": { + "200": { + "description": "The saved row", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamIntegrationRow" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Not enableable — no channel, no events, or a members-only event without the acknowledgement", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "teamId": { + "example": "any" + }, + "events": { + "example": "any" + }, + "channelRef": { + "example": "any" + }, + "enabled": { + "example": "any" + }, + "membersAck": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/teams/integrations/{teamId}": { + "delete": { + "tags": [ + "Admin · Teams" + ], + "summary": "Remove one bridge destination (admin only)", + "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.", + "parameters": [ + { + "name": "teamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Team id, or the literal string default." + } + ], + "responses": { + "200": { + "description": "Removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Nothing configured for that Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/teams/requests": { "get": { "tags": [ @@ -21484,6 +21652,277 @@ } } }, + "TeamIntegrationRow": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One bridge destination. `team_id` is null on the deployment-wide default row, which every Team without its own row inherits." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "platform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "discord" + } + } + }, + "team_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "team_name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "events": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "team.announcement" + } + } + } + } + }, + "channel_ref": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "1024839201048392010" + } + } + }, + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "members_ack": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "The operator has confirmed the destination channel is restricted to this Team’s members. Required before a members-only event may be enabled; cleared when the channel changes." + } + } + }, + "members_ack_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "members_ack_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "members_ack_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "updated_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "TeamIntegrationConfig": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "platform": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "discord" + } + } + }, + "events": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Every event that may be bridged, and whether it carries members-only content." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "team.forum.post" + } + } + }, + "membersOnly": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + } + } + } + } + } + } + }, + "rows": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamIntegrationRow" + } + } + } + } + } + } + }, "TeamModerationResult": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 62dbc12..d83ac14 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -1288,6 +1288,47 @@ const doc = { }, }, }, + TeamIntegrationRow: { + type: 'object', + description: + 'One bridge destination. `team_id` is null on the deployment-wide default row, which every Team without its own row inherits.', + properties: { + id: { type: 'integer' }, + platform: { type: 'string', example: 'discord' }, + team_id: { type: 'integer', nullable: true }, + team_name: { type: 'string', nullable: true }, + events: { type: 'array', items: { type: 'string', example: 'team.announcement' } }, + channel_ref: { type: 'string', nullable: true, example: '1024839201048392010' }, + enabled: { type: 'boolean' }, + members_ack: { + type: 'boolean', + description: + 'The operator has confirmed the destination channel is restricted to this Team’s members. Required before a members-only event may be enabled; cleared when the channel changes.', + }, + members_ack_by: { type: 'integer', nullable: true }, + members_ack_username: { type: 'string', nullable: true }, + members_ack_at: { type: 'string', format: 'date-time', nullable: true }, + updated_at: { type: 'string', format: 'date-time' }, + }, + }, + TeamIntegrationConfig: { + type: 'object', + properties: { + platform: { type: 'string', example: 'discord' }, + events: { + type: 'array', + description: 'Every event that may be bridged, and whether it carries members-only content.', + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'team.forum.post' }, + membersOnly: { type: 'boolean' }, + }, + }, + }, + rows: { type: 'array', items: { $ref: '#/components/schemas/TeamIntegrationRow' } }, + }, + }, TeamModerationResult: { type: 'object', description: diff --git a/server/test/teamBridge.test.js b/server/test/teamBridge.test.js new file mode 100644 index 0000000..d40e881 --- /dev/null +++ b/server/test/teamBridge.test.js @@ -0,0 +1,197 @@ +// The bridge as a SINK — what actually leaves the site (TEAMS.md §7.2, phase 8). +// +// teamIntegration.test.js proves the configuration rules; this proves the wiring +// that consumes them, which is a different set of mistakes: +// +// 1. **the bridge never throws and never blocks its caller.** Every entry point +// into teamNotify runs after a write has already been answered, so a bot +// that is down, a config lookup that throws, or a client that rejects must +// all come back as "no message sent" — an exception here would fail a forum +// reply that succeeded; +// 2. **the author is excluded from push and email and NOT from the bridge.** +// Excluding is a per-recipient idea; a channel has no per-recipient anything, +// and suppressing the message would silence it for everyone else; +// 3. **push still runs when the bridge is unconfigured**, which is the ordinary +// case on every deployment that never turns this on; +// 4. **a roster event carries a count and never a name.** The count is public; +// a character name is game-sourced text screened for a page, not a channel; +// 5. **the excerpt strips markup**, because a forum body is sanitised HTML and +// an embed description is text. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const teamBridge = require('../src/utils/teamBridge') +const teamIntegration = require('../src/model/teams/teamIntegration.model') +const botInternalClient = require('../src/utils/botInternalClient') +const teamNotify = require('../src/utils/teamNotify') +const notifyDb = require('../src/model/teams/teamNotify.db') +const forumSettings = require('../src/model/teams/teamForumSettings.model') +const pushDispatch = require('../src/utils/pushDispatch') +const mailer = require('../src/utils/mailer') + +const saved = new Map() + +function patch(mod, name, fn) { + if (!saved.has(mod)) saved.set(mod, new Map()) + if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) + mod[name] = fn +} + +function restore() { + for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn + saved.clear() +} + +const TEAM = { id: 3, name: 'Blackthorn’s Legion', slug: 'blackthorns-legion', external_id: 'g-1' } + +let sent +let pushed + +beforeEach(() => { + sent = [] + pushed = [] + patch(botInternalClient, 'teamNotify', async (payload) => { + sent.push(payload) + return { ok: true, status: 200, data: { posted: true } } + }) + patch(pushDispatch, 'publishToUsers', async (streamId, opts) => { pushed.push({ streamId, ...opts }) }) + patch(notifyDb, 'recipientIds', async () => [11, 12]) + patch(notifyDb, 'emailRecipients', async () => []) + patch(forumSettings, 'forumsEnabled', async () => true) + patch(mailer, 'isConfigured', async () => false) +}) + +afterEach(restore) + +const bridgeTo = (channelRef, events) => + patch(teamIntegration, 'destinationFor', async (teamId, streamId) => + (events.includes(streamId) ? { channelRef, membersOnly: teamIntegration.isMembersOnly(streamId), platform: 'discord' } : null)) + +const noBridge = () => patch(teamIntegration, 'destinationFor', async () => null) + +// ── 1. Never throws, never blocks ────────────────────────────────────────── + +test('a bot that rejects the call does not stop the notification path', async () => { + bridgeTo('999', ['team.member.joined']) + patch(botInternalClient, 'teamNotify', async () => ({ ok: false, status: 503, error: 'bot responded 503' })) + + const recipients = await teamNotify.memberJoined(TEAM, { count: 2 }) + assert.equal(recipients, 2, 'the push half still reported its recipients') + assert.equal(pushed.length, 1) +}) + +test('a config lookup that throws is a bridge that sends nothing, not an exception', async () => { + patch(teamIntegration, 'destinationFor', async () => { throw new Error('connection lost') }) + assert.equal(await teamBridge.deliver('team.member.joined', TEAM, { body: 'x' }), false) +}) + +test('a client that throws outright is caught', async () => { + bridgeTo('999', ['team.member.joined']) + patch(botInternalClient, 'teamNotify', async () => { throw new Error('socket hang up') }) + assert.equal(await teamBridge.deliver('team.member.joined', TEAM, { body: 'x' }), false) +}) + +test('a team with no id is refused before anything is looked up', async () => { + patch(teamIntegration, 'destinationFor', async () => { throw new Error('should not be reached') }) + assert.equal(await teamBridge.deliver('team.member.joined', null, {}), false) + assert.equal(await teamBridge.deliver('team.member.joined', {}, {}), false) +}) + +// ── 2. The author exclusion stops at the channel ─────────────────────────── + +test('a forum post excludes its author from push but still bridges to the channel', async () => { + bridgeTo('999', ['team.forum.post']) + const seen = [] + patch(notifyDb, 'recipientIds', async (teamId, opts) => { seen.push(opts.exclude); return [12] }) + + const result = await teamNotify.forumPost({ + team: TEAM, + threadId: 41, + threadTitle: 'Siege tonight', + type: 'discussion', + authorUserId: 11, + authorName: 'ana', + bodyHtml: '

Meet at the moongate.

', + }) + + assert.deepEqual(seen[0], [11], 'the author is excluded from the recipient set') + assert.equal(result.bridged, true) + assert.equal(sent.length, 1) + assert.equal(sent[0].title, 'Siege tonight') + assert.equal(sent[0].body, 'Meet at the moongate.') +}) + +test('an announcement bridges on its own stream, not the discussion one', async () => { + bridgeTo('999', ['team.announcement']) + await teamNotify.forumPost({ + team: TEAM, threadId: 7, threadTitle: 'Rules', type: 'announcement', bodyHtml: '

Read this.

', + }) + assert.equal(sent.length, 1) + assert.equal(sent[0].streamId, 'team.announcement') + + // The same event under the other stream is not carried by this configuration. + sent.length = 0 + await teamNotify.forumPost({ + team: TEAM, threadId: 8, threadTitle: 'Chat', type: 'discussion', bodyHtml: '

Hi.

', + }) + assert.equal(sent.length, 0) +}) + +test('forums switched off silence the bridge as well as the push', async () => { + bridgeTo('999', ['team.forum.post']) + patch(forumSettings, 'forumsEnabled', async () => false) + const result = await teamNotify.forumPost({ + team: TEAM, threadId: 41, threadTitle: 'Siege', type: 'discussion', bodyHtml: '

x

', + }) + assert.deepEqual(result, { push: 0, emails: 0, bridged: false }) + assert.equal(sent.length, 0) +}) + +// ── 3. The ordinary deployment: nothing configured ───────────────────────── + +test('an unconfigured bridge is silent and costs the push path nothing', async () => { + noBridge() + const recipients = await teamNotify.memberJoined(TEAM, { count: 1 }) + assert.equal(recipients, 2) + assert.equal(pushed.length, 1) + assert.equal(sent.length, 0) +}) + +// ── 4. A roster event says how many, never who ───────────────────────────── + +test('the roster message carries a count and no member name', async () => { + bridgeTo('999', ['team.member.joined']) + await teamNotify.memberJoined(TEAM, { count: 3 }) + assert.equal(sent[0].body, '3 new members joined.') + assert.equal(sent[0].title, null, 'a roster event has no title to put a name in') +}) + +test('the count is singular at one, and degrades honestly with no count at all', async () => { + assert.equal(teamBridge.memberJoinedBody(1), 'A new member joined.') + assert.equal(teamBridge.memberJoinedBody(4), '4 new members joined.') + assert.equal(teamBridge.memberJoinedBody(undefined), 'The roster has changed.') + assert.equal(teamBridge.memberJoinedBody(0), 'The roster has changed.') +}) + +test('the tickle stays content-free even when the bridge beside it carries a body', async () => { + bridgeTo('999', ['team.member.joined']) + await teamNotify.memberJoined(TEAM, { count: 3 }) + assert.deepEqual(pushed[0], { streamId: 'team.member.joined', ref: 'team:3', userIds: [11, 12] }) +}) + +// ── 5. The excerpt ───────────────────────────────────────────────────────── + +test('the excerpt strips markup, collapses whitespace and decodes entities', async () => { + assert.equal(teamBridge.excerpt('

Meet at\nthe moongate & wait.

'), 'Meet at the moongate & wait.') +}) + +test('the excerpt is bounded, because an embed description that overflows is rejected wholesale', async () => { + const long = teamBridge.excerpt(`

${'x'.repeat(5000)}

`) + assert.equal(long.length, teamBridge.EXCERPT_CHARS) + assert.ok(long.endsWith('…')) +}) + +test('the label prefers a staff display-name override, as every other surface does', async () => { + assert.equal(teamBridge.teamLabel({ name: 'Real', display_name_override: 'Shown' }), 'Shown') + assert.equal(teamBridge.teamLabel(null), 'a team') +}) diff --git a/server/test/teamIntegration.test.js b/server/test/teamIntegration.test.js new file mode 100644 index 0000000..dec43b9 --- /dev/null +++ b/server/test/teamIntegration.test.js @@ -0,0 +1,291 @@ +// The Team notification bridge (docs/website/TEAMS.md §7.2, phase 8). +// +// The db layer is stubbed and one in-memory table stands in for +// `team_integration_config`, so these are assertions about the RULES rather than +// about SQL. What they protect, in order of how badly it would hurt to lose it: +// +// 1. **A members-only event cannot be enabled without the acknowledgement**, +// and the refusal is a 422 rather than a quiet drop at delivery — a config +// that says it sends something it does not is worse than one that will not +// save; +// 2. **changing the channel clears a standing acknowledgement.** This is the +// whole reason the tick is a column and not a boolean somebody set once: it +// is a statement about a DESTINATION, and repointing the row at a public +// channel must not inherit the permission granted for a private one; +// 3. **the resolver re-checks the acknowledgement at delivery**, so a row that +// lost it stops carrying members-only events immediately rather than at the +// next save; +// 4. **the override beats the default, and the default is a real row** — the +// design-of-record's `PRIMARY KEY (platform, team_id)` could not hold it at +// all, so the base case of the whole override mechanism is worth a test; +// 5. **a failing lookup reports "unconfigured", not an exception**, because the +// caller is a notification path that must never fail the write behind it. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const db = require('../src/model/teams/teamIntegration.db') +const model = require('../src/model/teams/teamIntegration.model') + +const saved = new Map() + +function patch(mod, name, fn) { + if (!saved.has(mod)) saved.set(mod, new Map()) + if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) + mod[name] = fn +} + +function restore() { + for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn + saved.clear() +} + +// The stand-in stores `team_key` explicitly rather than deriving it on read, +// because that is what the generated column does and a test that folded NULL to 0 +// at lookup time would pass with a schema that never worked. +let rows + +const keyOf = (teamId) => (teamId === null || teamId === undefined ? 0 : Number(teamId)) + +beforeEach(() => { + rows = [] + let nextId = 1 + + patch(db, 'getForTeam', async (platform, teamId) => + rows.find((r) => r.platform === platform && r.team_key === keyOf(teamId)) || null) + + patch(db, 'resolveFor', async (platform, teamId) => { + const candidates = rows + .filter((r) => r.platform === platform && (r.team_key === 0 || r.team_key === Number(teamId))) + .sort((a, b) => b.team_key - a.team_key) + return candidates[0] || null + }) + + patch(db, 'upsert', async (input) => { + const key = keyOf(input.teamId) + const existing = rows.find((r) => r.platform === input.platform && r.team_key === key) + const next = { + id: existing ? existing.id : nextId++, + platform: input.platform, + team_id: input.teamId === null || input.teamId === undefined ? null : Number(input.teamId), + team_key: key, + events: JSON.stringify(input.events), + channel_ref: input.channelRef, + enabled: input.enabled ? 1 : 0, + members_ack: input.membersAck ? 1 : 0, + members_ack_by: input.membersAckBy, + members_ack_at: input.membersAckAt, + } + if (existing) rows[rows.indexOf(existing)] = next + else rows.push(next) + return next + }) + + patch(db, 'listForPlatform', async (platform) => rows.filter((r) => r.platform === platform)) + patch(db, 'remove', async (platform, teamId) => { + const before = rows.length + rows = rows.filter((r) => !(r.platform === platform && r.team_key === keyOf(teamId))) + return before - rows.length + }) +}) + +afterEach(restore) + +const ROSTER = 'team.member.joined' +const FORUM = 'team.forum.post' + +// ── 1. The acknowledgement is a precondition ─────────────────────────────── + +test('enabling a forum event without the acknowledgement is refused 422', async () => { + await assert.rejects( + () => model.save({ teamId: null, events: [FORUM], channelRef: '123456789', enabled: true }, 7), + (err) => { + assert.equal(err.status, 422) + assert.equal(err.code, 'members_ack_required') + return true + }, + ) + assert.equal(rows.length, 0, 'nothing was written') +}) + +test('a roster-only bridge needs no acknowledgement', async () => { + const row = await model.save({ teamId: null, events: [ROSTER], channelRef: '123456789', enabled: true }, 7) + assert.equal(row.enabled, true) + assert.equal(row.members_ack, false) +}) + +test('a DISABLED row may carry a forum event without the acknowledgement', async () => { + // Drafting a configuration is not publishing one. Refusing the save would make + // an operator tick a box before they had decided to turn anything on. + const row = await model.save({ teamId: null, events: [FORUM], channelRef: '123456789', enabled: false }, 7) + assert.deepEqual(row.events, [FORUM]) + assert.equal(row.enabled, false) +}) + +test('an enabled bridge with no channel is refused before the acknowledgement is even considered', async () => { + await assert.rejects( + () => model.save({ teamId: null, events: [FORUM], channelRef: '', enabled: true, membersAck: true }, 7), + (err) => { + assert.equal(err.status, 422) + assert.match(err.message, /destination channel/) + return true + }, + ) +}) + +test('the acknowledgement records who gave it, and an unrelated save does not re-stamp it', async () => { + await model.save({ teamId: null, events: [FORUM], channelRef: '123456789', enabled: true, membersAck: true }, 7) + const first = await db.getForTeam('discord', null) + assert.equal(first.members_ack_by, 7) + assert.ok(first.members_ack_at) + + // A different admin adds a roster event. The acknowledgement is unchanged and + // still attributed to the person who actually gave it. + await model.save({ teamId: null, events: [FORUM, ROSTER], channelRef: '123456789', enabled: true }, 99) + const second = await db.getForTeam('discord', null) + assert.equal(second.members_ack_by, 7) + assert.deepEqual(second.members_ack_at, first.members_ack_at) +}) + +// ── 2. The acknowledgement dies with its channel ─────────────────────────── + +test('changing the channel clears the acknowledgement — and so refuses the save that would have kept it', async () => { + await model.save({ teamId: null, events: [FORUM], channelRef: '111111111', enabled: true, membersAck: true }, 7) + + await assert.rejects( + () => model.save({ teamId: null, events: [FORUM], channelRef: '222222222', enabled: true }, 7), + (err) => err.status === 422 && err.code === 'members_ack_required', + ) + + // The stored row still points at the original channel: a refused save writes + // nothing, so the bridge keeps working against the destination that was vetted. + const row = await db.getForTeam('discord', null) + assert.equal(row.channel_ref, '111111111') +}) + +test('changing the channel WITH a fresh acknowledgement saves and re-stamps', async () => { + await model.save({ teamId: null, events: [FORUM], channelRef: '111111111', enabled: true, membersAck: true }, 7) + const row = await model.save( + { teamId: null, events: [FORUM], channelRef: '222222222', enabled: true, membersAck: true }, + 9, + ) + assert.equal(row.channel_ref, '222222222') + assert.equal(row.members_ack, true) + const stored = await db.getForTeam('discord', null) + assert.equal(stored.members_ack_by, 9, 're-acknowledged by whoever repointed it') +}) + +test('an explicit withdrawal is honoured, and takes the enabled forum bridge with it', async () => { + await model.save({ teamId: null, events: [FORUM], channelRef: '111111111', enabled: true, membersAck: true }, 7) + await assert.rejects( + () => model.save({ teamId: null, events: [FORUM], channelRef: '111111111', enabled: true, membersAck: false }, 7), + (err) => err.status === 422, + ) +}) + +// ── 3. The resolver re-asks at delivery ──────────────────────────────────── + +test('a row whose acknowledgement was lost stops carrying its members-only events', async () => { + await model.save({ teamId: null, events: [FORUM, ROSTER], channelRef: '111111111', enabled: true, membersAck: true }, 7) + + // Simulate the column going false underneath the row — an admin edit through a + // path that cleared it, or a future reclassification of what counts as + // members-only. The resolver must not serve it on the strength of the save. + rows[0].members_ack = 0 + + const resolved = await model.resolve(1) + assert.deepEqual(resolved.events, [ROSTER], 'the forum event is filtered out, the roster one survives') + assert.equal(await model.destinationFor(1, FORUM), null) + assert.ok(await model.destinationFor(1, ROSTER)) +}) + +test('a disabled row and a row with no channel both resolve to nothing', async () => { + await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: false }, 7) + assert.equal(await model.resolve(1), null) + + rows[0].enabled = 1 + rows[0].channel_ref = null + assert.equal(await model.resolve(1), null) +}) + +test('destinationFor answers null for an event the row does not carry', async () => { + await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7) + assert.equal(await model.destinationFor(1, FORUM), null) +}) + +// ── 4. The default row, and the override that beats it ───────────────────── + +test('the deployment default applies to a Team with no row of its own', async () => { + await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7) + const destination = await model.destinationFor(42, ROSTER) + assert.equal(destination.channelRef, '111111111') +}) + +test('a per-Team row overrides the default rather than adding to it', async () => { + await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7) + await model.save({ teamId: 42, events: [ROSTER], channelRef: '222222222', enabled: true }, 7) + + assert.equal((await model.destinationFor(42, ROSTER)).channelRef, '222222222') + assert.equal((await model.destinationFor(7, ROSTER)).channelRef, '111111111', 'other Teams keep the default') +}) + +test('a per-Team row can switch the bridge OFF for one Team while the default stays on', async () => { + await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7) + await model.save({ teamId: 42, events: [ROSTER], channelRef: '222222222', enabled: false }, 7) + + assert.equal(await model.destinationFor(42, ROSTER), null, 'the override wins even when it disables') + assert.ok(await model.destinationFor(7, ROSTER)) +}) + +test('the default row and a per-Team row coexist — the key folds NULL to 0 and nothing collides', async () => { + await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7) + await model.save({ teamId: 1, events: [ROSTER], channelRef: '222222222', enabled: true }, 7) + await model.save({ teamId: 2, events: [ROSTER], channelRef: '333333333', enabled: true }, 7) + assert.equal(rows.length, 3) + assert.deepEqual(rows.map((r) => r.team_key).sort(), [0, 1, 2]) +}) + +test('removing a per-Team override drops that Team back to the default', async () => { + await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7) + await model.save({ teamId: 42, events: [ROSTER], channelRef: '222222222', enabled: false }, 7) + assert.equal(await model.destinationFor(42, ROSTER), null) + + assert.equal(await model.remove('discord', 42), 1) + assert.equal((await model.destinationFor(42, ROSTER)).channelRef, '111111111') +}) + +// ── 5. Failing closed, and validation ────────────────────────────────────── + +test('a lookup that throws reports "unconfigured" rather than propagating', async () => { + patch(db, 'resolveFor', async () => { throw new Error('connection lost') }) + assert.equal(await model.resolve(1), null) + assert.equal(await model.destinationFor(1, ROSTER), null) +}) + +test('an unknown event id is rejected, not silently dropped', async () => { + await assert.rejects( + () => model.save({ teamId: null, events: ['team.forum.pots'], channelRef: '111111111', enabled: false }, 7), + (err) => err.status === 400 && /unknown event/.test(err.message), + ) +}) + +test('duplicate event ids collapse, and a non-array is a 400', async () => { + const row = await model.save({ teamId: null, events: [ROSTER, ROSTER], channelRef: '111111111', enabled: true }, 7) + assert.deepEqual(row.events, [ROSTER]) + await assert.rejects(() => model.save({ teamId: null, events: ROSTER, channelRef: '1', enabled: false }, 7), + (err) => err.status === 400) +}) + +test('a channel ref that is not a plain id is refused', async () => { + await assert.rejects( + () => model.save({ teamId: null, events: [ROSTER], channelRef: '#general', enabled: true }, 7), + (err) => err.status === 400, + ) +}) + +test('a corrupt stored event list renders as "bridges nothing" rather than throwing', async () => { + await model.save({ teamId: null, events: [ROSTER], channelRef: '111111111', enabled: true }, 7) + rows[0].events = '{not json' + assert.equal(await model.resolve(1), null) + const listed = await model.list('discord') + assert.deepEqual(listed[0].events, []) +}) diff --git a/server/test/teamNotifyDispatch.test.js b/server/test/teamNotifyDispatch.test.js index 11ae236..1665247 100644 --- a/server/test/teamNotifyDispatch.test.js +++ b/server/test/teamNotifyDispatch.test.js @@ -102,7 +102,10 @@ test('roster events fire one tickle for the run, not one per member', async () = test('forums switched off silences a forum notification entirely', async () => { stub({ forumsEnabled: false }) const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 }) - assert.deepEqual(res, { push: 0, emails: 0 }) + // `bridged` is phase 8's third sink (§7.2). Asserted as part of the shape + // rather than ignored: "forums are off" has to silence every sink, and a test + // that only checked two would not notice a third one still firing. + assert.deepEqual(res, { push: 0, emails: 0, bridged: false }) assert.equal(sent.length, 0) }) @@ -121,7 +124,7 @@ test('no recipients means no publish call at all', async () => { test('a fan-out never throws, whatever the layer below does', async () => { patch(notifyModel, 'recipientIds', async () => { throw new Error('database is on fire') }) const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 }) - assert.deepEqual(res, { push: 0, emails: 0 }) + assert.deepEqual(res, { push: 0, emails: 0, bridged: false }) assert.equal(await notify.memberJoined(TEAM), 0) })