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