Merge pull request 'feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check' (#158) from feature/teams-phase8-notifications-bridge into edge

Reviewed-on: #158
This commit is contained in:
2026-08-19 01:33:13 +00:00
25 changed files with 2567 additions and 11 deletions

View File

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

View File

@@ -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,
}

View File

@@ -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

138
bot/test/teamNotify.test.js Normal file
View File

@@ -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: 'Blackthorns 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, 'Blackthorns 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')
})

View File

@@ -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')

View File

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

View File

@@ -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 Teams 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 (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
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 (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
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.
</p>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
{config.rows.length === 0 && !draft && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured no Team events leave the site.</p>
)}
{config.rows.length > 0 && (
<table className="table">
<thead>
<tr><th>Applies to</th><th>Events</th><th>Channel</th><th>State</th><th /></tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={rowKey(row)}>
<td>
{appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block' }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{!draft && (
<div style={{ marginTop: 12 }}>
{!hasDefault && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(null))}>
Set a default for all Teams
</button>
)}
{available.length > 0 && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(available[0].id))}>
Add a per-Team override
</button>
)}
</div>
)}
{draft && (
<div style={{ marginTop: 18, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Applies to</span>
<select
className="select"
value={draft.teamId === null ? 'default' : String(draft.teamId)}
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
>
<option value="default">All Teams (default)</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
))}
</select>
</label>
<span className="field-label">Events to send</span>
{config.events.map((event) => (
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
<input
type="checkbox"
checked={draft.events.includes(event.id)}
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
style={{ marginRight: 8 }}
/>
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
{event.membersOnly && (
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
)}
</label>
))}
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Channel id</span>
<input
className="input"
value={draft.channelRef}
// Changing the channel drops a standing acknowledgement in the SAME
// place the server does. Leaving the tick showing while the server
// has already decided to clear it would let an operator repoint a row
// at a public channel and believe the confirmation still covered it.
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
placeholder="1024839201048392010"
style={{ maxWidth: 280 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
the new channels audience again.
</span>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
</label>
{draft.membersAck && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
You have confirmed this channel is restricted to the Teams members.{' '}
<button type="button" className="btn-ghost" onClick={() => setDraft({ ...draft, membersAck: false })}>
Withdraw
</button>
</p>
)}
<div style={{ marginTop: 16 }}>
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
</div>
</div>
)}
{dialog && (
<div style={{ marginTop: 18, border: '1px solid #e0b877', padding: 14, borderRadius: 4 }}>
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destinations audience</h3>
{ACK_TEXT.map((line) => (
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
))}
<button
type="button"
className="btn"
disabled={busy}
onClick={() => persist({ ...dialog, membersAck: true })}
>
I confirm the channel is members-only
</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
</div>
)}
</section>
)
}

View File

@@ -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 && <ForumLedger team={ledgerTeam} onClose={() => 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' && <TeamIntegrations />}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />

View File

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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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"

View File

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

View File

@@ -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 Teams 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,
}

View File

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

View File

@@ -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,

View File

@@ -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 bridges 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 Teams 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 channels permissions, so the operators 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']

View File

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

View File

@@ -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<boolean>} 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/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 }

View File

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

View File

@@ -4604,6 +4604,174 @@
]
}
},
"/api/v1/admin/teams/integrations": {
"get": {
"tags": [
"Admin · Teams"
],
"summary": "The Team notification bridges 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 Teams 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 channels permissions, so the operators 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 Teams 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": {

View File

@@ -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 Teams 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:

View File

@@ -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: 'Blackthorns 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: '<p>Meet at the moongate.</p>',
})
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: '<p>Read this.</p>',
})
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: '<p>Hi.</p>',
})
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: '<p>x</p>',
})
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('<p>Meet at\nthe <b>moongate</b> &amp; wait.</p>'), 'Meet at the moongate & wait.')
})
test('the excerpt is bounded, because an embed description that overflows is rejected wholesale', async () => {
const long = teamBridge.excerpt(`<p>${'x'.repeat(5000)}</p>`)
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')
})

View File

@@ -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, [])
})

View File

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