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:
80
bot/src/discord/teamNotify.js
Normal file
80
bot/src/discord/teamNotify.js
Normal 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 }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
138
bot/test/teamNotify.test.js
Normal 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: 'Blackthorn’s Legion',
|
||||
teamUrl: 'https://site/guilds/blackthorns-legion',
|
||||
title: 'Siege tonight',
|
||||
body: 'Meet at the moongate.',
|
||||
url: 'https://site/guilds/blackthorns-legion?thread=41',
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── 1. The channel is the app's decision ───────────────────────────────────
|
||||
|
||||
test('the message goes to the channel the app named', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel))
|
||||
assert.equal(channel.sends.length, 1)
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'Siege tonight')
|
||||
assert.equal(embed.data.author.name, 'Blackthorn’s Legion')
|
||||
assert.equal(embed.data.url, 'https://site/guilds/blackthorns-legion?thread=41')
|
||||
})
|
||||
|
||||
test('no channel id at all is refused before anything is fetched', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel()), { channelId: '' }), /No channel id/)
|
||||
})
|
||||
|
||||
// ── 2. A channel the bot cannot use ────────────────────────────────────────
|
||||
|
||||
test('a channel the bot cannot see is a clear error, not a silent no-op', async () => {
|
||||
await assert.rejects(() => post(fakeClient(null)), /missing, not text-based, or not visible/)
|
||||
})
|
||||
|
||||
test('a fetch that throws is reported the same way — the bot does not distinguish gone from hidden', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel(), { throws: true })), /missing, not text-based/)
|
||||
})
|
||||
|
||||
test('a voice channel is refused', async () => {
|
||||
await assert.rejects(() => post(fakeClient(fakeChannel({ textBased: false }))), /not text-based/)
|
||||
})
|
||||
|
||||
// ── 3. Discord's limits, and the heading ───────────────────────────────────
|
||||
|
||||
test('an over-long title is truncated rather than rejected by Discord as a whole', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { title: 'y'.repeat(400) })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title.length, teamNotify.TITLE_MAX)
|
||||
assert.ok(embed.data.title.endsWith('…'))
|
||||
})
|
||||
|
||||
test('an over-long body is truncated to the description limit', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { body: 'z'.repeat(9000) })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.ok(embed.data.description.length <= teamNotify.DESCRIPTION_MAX + 32)
|
||||
})
|
||||
|
||||
test('a titled event keeps its heading, so a post and an announcement stay distinguishable', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.announcement' })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.match(embed.data.description, /^\*\*Announcement\*\*/)
|
||||
assert.match(embed.data.description, /Meet at the moongate\./)
|
||||
})
|
||||
|
||||
test('a roster event has no title, so the heading becomes the title', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.member.joined', title: null, body: '3 new members joined.' })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'New member')
|
||||
assert.equal(embed.data.description, '3 new members joined.', 'no heading prefix when the title already is one')
|
||||
})
|
||||
|
||||
test('an unknown stream still posts, under a neutral heading', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { stream: 'team.something.new', title: null })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.title, 'Team update')
|
||||
})
|
||||
|
||||
test('a missing team name does not produce an embed with an empty author line', async () => {
|
||||
const channel = fakeChannel()
|
||||
await post(fakeClient(channel), { teamName: '', teamUrl: null })
|
||||
const [embed] = channel.sends[0].embeds
|
||||
assert.equal(embed.data.author.name, 'A team')
|
||||
assert.equal(embed.data.author.url, undefined)
|
||||
})
|
||||
|
||||
test('clamp treats whitespace-only as absent, which is what keeps an empty description off the embed', async () => {
|
||||
assert.equal(teamNotify.clamp(' ', 100), null)
|
||||
assert.equal(teamNotify.clamp('ok', 100), 'ok')
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
103
client/src/lib/teamIntegrations.js
Normal file
103
client/src/lib/teamIntegrations.js
Normal 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)),
|
||||
}
|
||||
}
|
||||
281
client/src/routes/admin/views/TeamIntegrations.jsx
Normal file
281
client/src/routes/admin/views/TeamIntegrations.jsx
Normal 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 Team’s members. This site cannot see who can'
|
||||
+ ' read a channel on another platform, so it cannot check that for you.',
|
||||
'By enabling these events you confirm that the destination channel is restricted to the members of'
|
||||
+ ' the Team whose posts it will carry.',
|
||||
]
|
||||
|
||||
export default function TeamIntegrations() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [teams, setTeams] = useState([])
|
||||
const [draft, setDraft] = useState(null)
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
|
||||
setConfig(cfg)
|
||||
setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
|
||||
} catch (err) {
|
||||
// A moderator never reaches this panel — the admin nav does not render it —
|
||||
// so a 403 here means the role changed underneath an open tab rather than a
|
||||
// routing mistake, and saying so beats "could not load".
|
||||
setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<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 channel’s 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 Team’s 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 destination’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
129
client/test/teamIntegrations.test.js
Normal file
129
client/test/teamIntegrations.test.js
Normal 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')
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
122
server/src/model/teams/teamIntegration.db.js
Normal file
122
server/src/model/teams/teamIntegration.db.js
Normal 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 }
|
||||
272
server/src/model/teams/teamIntegration.model.js
Normal file
272
server/src/model/teams/teamIntegration.model.js
Normal 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 Team’s members — confirm the destination channel is restricted to them before enabling',
|
||||
)
|
||||
err.status = 422
|
||||
err.code = 'members_ack_required'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows for the admin panel, `events` already parsed. */
|
||||
async function list(platform = DISCORD) {
|
||||
const rows = await db.listForPlatform(platform)
|
||||
return rows.map(shape)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the stored JSON once, here.
|
||||
*
|
||||
* `mariadb` hands a JSON column back as a string on some server versions and as a
|
||||
* parsed value on others, which is a difference nobody wants to rediscover in a
|
||||
* controller. Anything unreadable becomes an empty list rather than a throw: a
|
||||
* row with a corrupt event list should render as a row that bridges nothing, not
|
||||
* take the whole admin page down.
|
||||
*/
|
||||
function shape(row) {
|
||||
if (!row) return null
|
||||
let events = row.events
|
||||
if (typeof events === 'string') {
|
||||
try {
|
||||
events = JSON.parse(events)
|
||||
} catch {
|
||||
events = []
|
||||
}
|
||||
}
|
||||
return { ...row, events: Array.isArray(events) ? events : [], enabled: !!row.enabled, members_ack: !!row.members_ack }
|
||||
}
|
||||
|
||||
/**
|
||||
* The row that governs `teamId` — the override if there is one, otherwise the
|
||||
* deployment default — filtered down to what may actually be delivered.
|
||||
*
|
||||
* **The acknowledgement is checked HERE as well as at the save.** A row saved
|
||||
* with the tick can lose it later: an admin repoints the channel, or a future
|
||||
* change to what counts as members-only reclassifies a stream a row already
|
||||
* carries. Re-asking at delivery is what makes the tick a live property of the
|
||||
* row rather than a note about a save that happened once.
|
||||
*/
|
||||
async function resolve(teamId, platform = DISCORD) {
|
||||
try {
|
||||
const row = shape(await db.resolveFor(platform, teamId))
|
||||
if (!row || !row.enabled || !row.channel_ref) return null
|
||||
const events = row.events.filter((id) => (isMembersOnly(id) ? row.members_ack : true))
|
||||
if (events.length === 0) return null
|
||||
return { ...row, events }
|
||||
} catch (err) {
|
||||
log.warn('bridge config lookup failed — treating as unconfigured', {
|
||||
teamId,
|
||||
platform,
|
||||
message: err.message,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Is `streamId` bridged for this Team? The delivery path's whole question. */
|
||||
async function destinationFor(teamId, streamId, platform = DISCORD) {
|
||||
const row = await resolve(teamId, platform)
|
||||
if (!row || !row.events.includes(streamId)) return null
|
||||
return { channelRef: row.channel_ref, membersOnly: isMembersOnly(streamId), platform }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace the row for (platform, team).
|
||||
*
|
||||
* `actorId` is the admin doing the saving, and it is what lands in
|
||||
* `members_ack_by` — the acknowledgement names a person, so it cannot be written
|
||||
* by a path that does not know who they are.
|
||||
*/
|
||||
async function save({ platform = DISCORD, teamId = null, events, channelRef, enabled, membersAck }, actorId) {
|
||||
if (!PLATFORMS.includes(platform)) {
|
||||
const err = new Error(`unknown platform: ${platform}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
|
||||
const nextEvents = normaliseEvents(events)
|
||||
const nextChannel = normaliseChannel(channelRef)
|
||||
const existing = shape(await db.getForTeam(platform, teamId))
|
||||
|
||||
// An acknowledgement survives an ordinary edit and dies with the channel it was
|
||||
// given for. `membersAck === false` from the client is an explicit withdrawal
|
||||
// and is honoured; `undefined` means "leave it", which is what a save that only
|
||||
// toggled an event should do.
|
||||
const channelChanged = !!existing && existing.channel_ref !== nextChannel
|
||||
let ack = existing ? existing.members_ack : false
|
||||
if (membersAck === false) ack = false
|
||||
else if (membersAck === true) ack = true
|
||||
if (channelChanged) ack = membersAck === true
|
||||
|
||||
const nextEnabled = !!enabled
|
||||
assertEnableable({ enabled: nextEnabled, events: nextEvents, channelRef: nextChannel, membersAck: ack })
|
||||
|
||||
// Re-stamp only when the acknowledgement is newly given, so an unrelated save
|
||||
// does not rewrite the date on a decision nobody revisited.
|
||||
//
|
||||
// `channelChanged` belongs in this condition and it is easy to leave out: an
|
||||
// acknowledgement given alongside a NEW channel is a new acknowledgement even
|
||||
// though the column was already 1, and without it the row keeps naming whoever
|
||||
// vetted the PREVIOUS destination. That attribution is the whole audit value of
|
||||
// the column — it has to name the person who looked at the channel the row now
|
||||
// points at.
|
||||
const freshlyAcked = ack && (channelChanged || !(existing && existing.members_ack))
|
||||
const row = await db.upsert({
|
||||
platform,
|
||||
teamId,
|
||||
events: nextEvents,
|
||||
channelRef: nextChannel,
|
||||
enabled: nextEnabled,
|
||||
membersAck: ack,
|
||||
membersAckBy: ack ? (freshlyAcked ? actorId : existing.members_ack_by) : null,
|
||||
membersAckAt: ack ? (freshlyAcked ? new Date() : existing.members_ack_at) : null,
|
||||
})
|
||||
return shape(row)
|
||||
}
|
||||
|
||||
async function remove(platform, teamId) {
|
||||
return db.remove(platform, teamId)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DISCORD,
|
||||
PLATFORMS,
|
||||
BRIDGEABLE,
|
||||
MEMBERS_ONLY,
|
||||
isMembersOnly,
|
||||
needsAck,
|
||||
normaliseEvents,
|
||||
normaliseChannel,
|
||||
assertEnableable,
|
||||
list,
|
||||
resolve,
|
||||
destinationFor,
|
||||
save,
|
||||
remove,
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -16,6 +16,7 @@ const forum = require('../../../model/teams/teamForum.model')
|
||||
const forumDb = require('../../../model/teams/teamForum.db')
|
||||
const forumUploadsModel = require('../../../model/teams/teamForumUploads.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const integration = require('../../../model/teams/teamIntegration.model')
|
||||
|
||||
const log = require('../../../utils/logger')('teams')
|
||||
|
||||
@@ -265,7 +266,91 @@ async function decideRequest(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── The integration bridge (§7.2, phase 8) — admin only ───────────────────
|
||||
//
|
||||
// Admin-only at the ROUTER, unlike everything above it. The §2.9 gate exists
|
||||
// because a moderator's action publishes untrusted game strings to the public
|
||||
// site; this is a different risk in the other direction — it decides that
|
||||
// members-only forum text leaves the site altogether, for a destination core
|
||||
// cannot see. That is a deployment-configuration decision, and it sits with the
|
||||
// role that holds the bot token rather than with the queue.
|
||||
|
||||
async function integrationConfig(req, res) {
|
||||
try {
|
||||
return res.json({
|
||||
platform: integration.DISCORD,
|
||||
events: integration.BRIDGEABLE.map((id) => ({ id, membersOnly: integration.isMembersOnly(id) })),
|
||||
rows: await integration.list(integration.DISCORD),
|
||||
})
|
||||
} catch (err) {
|
||||
return fail(res, err, 'integration config')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIntegrationConfig(req, res) {
|
||||
try {
|
||||
// `teamId` null is the deployment default and is a legitimate body, so the
|
||||
// absent-vs-null distinction matters: a PUT with no teamId edits the default.
|
||||
const teamId = req.body.teamId === undefined || req.body.teamId === null ? null : Number(req.body.teamId)
|
||||
if (teamId !== null && !(await teamsDb.findById(teamId))) {
|
||||
return res.status(404).json({ message: 'Team not found' })
|
||||
}
|
||||
|
||||
const row = await integration.save(
|
||||
{
|
||||
platform: integration.DISCORD,
|
||||
teamId,
|
||||
events: req.body.events,
|
||||
channelRef: req.body.channelRef,
|
||||
enabled: req.body.enabled,
|
||||
membersAck: req.body.membersAck,
|
||||
},
|
||||
req.user.id,
|
||||
)
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.integration.save',
|
||||
detail:
|
||||
`${req.user.username} (#${req.user.id}) saved the ${integration.DISCORD} bridge for ` +
|
||||
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}: ` +
|
||||
`${row.enabled ? 'enabled' : 'disabled'}, events [${row.events.join(', ')}]` +
|
||||
`${row.members_ack ? ', members-only destination acknowledged' : ''}`,
|
||||
})
|
||||
|
||||
return res.json(row)
|
||||
} catch (err) {
|
||||
// A validation refusal carries its own status and its own wording — the
|
||||
// acknowledgement message in particular is the whole explanation of why the
|
||||
// save was refused, and collapsing it into a 500 would leave the operator
|
||||
// with a screen that will not save and no reason given.
|
||||
if (err.status) return res.status(err.status).json({ message: err.message, code: err.code })
|
||||
return fail(res, err, 'save integration config')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIntegrationConfig(req, res) {
|
||||
try {
|
||||
const teamId = req.params.teamId === 'default' ? null : Number(req.params.teamId)
|
||||
const removed = await integration.remove(integration.DISCORD, teamId)
|
||||
if (removed === 0) return res.status(404).json({ message: 'No configuration for that Team' })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.integration.delete',
|
||||
detail:
|
||||
`${req.user.username} (#${req.user.id}) removed the ${integration.DISCORD} bridge for ` +
|
||||
`${teamId === null ? 'all Teams (default)' : `Team #${teamId}`}`,
|
||||
})
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return fail(res, err, 'delete integration config')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
integrationConfig,
|
||||
saveIntegrationConfig,
|
||||
deleteIntegrationConfig,
|
||||
forumModeration,
|
||||
forumUploads,
|
||||
forumSettingsState,
|
||||
|
||||
@@ -19,9 +19,16 @@ const { body, param, query } = require('express-validator')
|
||||
|
||||
const ctrl = require('./teams.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const teamsRouter = express.Router()
|
||||
|
||||
// The one ADMIN-only corner of a staff-wide router (§7.2, phase 8). Configuring
|
||||
// where a Team's events leave the site for is not the §2.9 kind of decision a
|
||||
// moderator files a request for; it is deployment configuration, and it sits with
|
||||
// the role that already holds the bot token.
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
// ── Literal paths, first ───────────────────────────────────────────────────
|
||||
|
||||
teamsRouter.get(
|
||||
@@ -123,6 +130,54 @@ teamsRouter.get(
|
||||
ctrl.forumSettingsState,
|
||||
)
|
||||
|
||||
// ── The integration bridge (§7.2) — literal, and before /:id ──────────────
|
||||
|
||||
teamsRouter.get(
|
||||
'/integrations',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The Team notification bridge’s configuration (admin only)'
|
||||
// #swagger.description = 'Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Team’s members — recorded here with who gave it.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Bridge configuration', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationConfig" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.integrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.put(
|
||||
'/integrations',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Create or replace one bridge destination (admin only)'
|
||||
// #swagger.description = 'Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channel’s permissions, so the operator’s acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The saved row', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamIntegrationRow" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[422] = { description: 'Not enableable — no channel, no events, or a members-only event without the acknowledgement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('teamId').optional({ nullable: true }).isInt({ min: 1 }).toInt(),
|
||||
body('events').isArray({ max: 8 }),
|
||||
body('channelRef').optional({ nullable: true }).isString().trim().isLength({ max: 64 }),
|
||||
body('enabled').optional().isBoolean().toBoolean(),
|
||||
body('membersAck').optional().isBoolean().toBoolean(),
|
||||
validate,
|
||||
ctrl.saveIntegrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.delete(
|
||||
'/integrations/:teamId',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Remove one bridge destination (admin only)'
|
||||
// #swagger.description = 'Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.'
|
||||
// #swagger.parameters['teamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Team id, or the literal string default.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Nothing configured for that Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('teamId').custom((v) => v === 'default' || /^[0-9]+$/.test(v)),
|
||||
validate,
|
||||
ctrl.deleteIntegrationConfig,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
|
||||
@@ -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 }
|
||||
|
||||
120
server/src/utils/teamBridge.js
Normal file
120
server/src/utils/teamBridge.js
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return text.length > EXCERPT_CHARS ? `${text.slice(0, EXCERPT_CHARS - 1)}…` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* "3 new members joined." — a count, and never a name.
|
||||
*
|
||||
* The roster sync notifies once per run rather than once per member (§6.2), so a
|
||||
* count is all the caller has; it is also all this should say. A member's
|
||||
* character name is game-sourced text that has been through core's reserved-name
|
||||
* screening for a PAGE, not for a channel, and the roster it comes from is on a
|
||||
* public page anybody in that channel can already open.
|
||||
*/
|
||||
function memberJoinedBody(count) {
|
||||
const n = Number(count) || 0
|
||||
if (n <= 0) return 'The roster has changed.'
|
||||
return n === 1 ? 'A new member joined.' : `${n} new members joined.`
|
||||
}
|
||||
|
||||
module.exports = { deliver, excerpt, teamLabel, memberJoinedBody, EXCERPT_CHARS }
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4604,6 +4604,174 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/integrations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "The Team notification bridge’s configuration (admin only)",
|
||||
"description": "Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Team’s members — recorded here with who gave it.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Bridge configuration",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamIntegrationConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Admin role required",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "Create or replace one bridge destination (admin only)",
|
||||
"description": "Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channel’s permissions, so the operator’s acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The saved row",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamIntegrationRow"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such Team",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Not enableable — no channel, no events, or a members-only event without the acknowledgement",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"teamId": {
|
||||
"example": "any"
|
||||
},
|
||||
"events": {
|
||||
"example": "any"
|
||||
},
|
||||
"channelRef": {
|
||||
"example": "any"
|
||||
},
|
||||
"enabled": {
|
||||
"example": "any"
|
||||
},
|
||||
"membersAck": {
|
||||
"example": "any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/integrations/{teamId}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "Remove one bridge destination (admin only)",
|
||||
"description": "Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "teamId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Team id, or the literal string default."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OkResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "Nothing configured for that Team",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/requests": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -21484,6 +21652,277 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamIntegrationRow": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One bridge destination. `team_id` is null on the deployment-wide default row, which every Team without its own row inherits."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"platform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "discord"
|
||||
}
|
||||
}
|
||||
},
|
||||
"team_id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"team_name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "team.announcement"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_ref": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "1024839201048392010"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"members_ack": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The operator has confirmed the destination channel is restricted to this Team’s members. Required before a members-only event may be enabled; cleared when the channel changes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"members_ack_by": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"members_ack_username": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"members_ack_at": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamIntegrationConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"platform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "discord"
|
||||
}
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Every event that may be bridged, and whether it carries members-only content."
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "team.forum.post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"membersOnly": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rows": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TeamIntegrationRow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TeamModerationResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1288,6 +1288,47 @@ const doc = {
|
||||
},
|
||||
},
|
||||
},
|
||||
TeamIntegrationRow: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One bridge destination. `team_id` is null on the deployment-wide default row, which every Team without its own row inherits.',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
platform: { type: 'string', example: 'discord' },
|
||||
team_id: { type: 'integer', nullable: true },
|
||||
team_name: { type: 'string', nullable: true },
|
||||
events: { type: 'array', items: { type: 'string', example: 'team.announcement' } },
|
||||
channel_ref: { type: 'string', nullable: true, example: '1024839201048392010' },
|
||||
enabled: { type: 'boolean' },
|
||||
members_ack: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'The operator has confirmed the destination channel is restricted to this Team’s members. Required before a members-only event may be enabled; cleared when the channel changes.',
|
||||
},
|
||||
members_ack_by: { type: 'integer', nullable: true },
|
||||
members_ack_username: { type: 'string', nullable: true },
|
||||
members_ack_at: { type: 'string', format: 'date-time', nullable: true },
|
||||
updated_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
TeamIntegrationConfig: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
platform: { type: 'string', example: 'discord' },
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'Every event that may be bridged, and whether it carries members-only content.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'team.forum.post' },
|
||||
membersOnly: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
rows: { type: 'array', items: { $ref: '#/components/schemas/TeamIntegrationRow' } },
|
||||
},
|
||||
},
|
||||
TeamModerationResult: {
|
||||
type: 'object',
|
||||
description:
|
||||
|
||||
197
server/test/teamBridge.test.js
Normal file
197
server/test/teamBridge.test.js
Normal 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: 'Blackthorn’s Legion', slug: 'blackthorns-legion', external_id: 'g-1' }
|
||||
|
||||
let sent
|
||||
let pushed
|
||||
|
||||
beforeEach(() => {
|
||||
sent = []
|
||||
pushed = []
|
||||
patch(botInternalClient, 'teamNotify', async (payload) => {
|
||||
sent.push(payload)
|
||||
return { ok: true, status: 200, data: { posted: true } }
|
||||
})
|
||||
patch(pushDispatch, 'publishToUsers', async (streamId, opts) => { pushed.push({ streamId, ...opts }) })
|
||||
patch(notifyDb, 'recipientIds', async () => [11, 12])
|
||||
patch(notifyDb, 'emailRecipients', async () => [])
|
||||
patch(forumSettings, 'forumsEnabled', async () => true)
|
||||
patch(mailer, 'isConfigured', async () => false)
|
||||
})
|
||||
|
||||
afterEach(restore)
|
||||
|
||||
const bridgeTo = (channelRef, events) =>
|
||||
patch(teamIntegration, 'destinationFor', async (teamId, streamId) =>
|
||||
(events.includes(streamId) ? { channelRef, membersOnly: teamIntegration.isMembersOnly(streamId), platform: 'discord' } : null))
|
||||
|
||||
const noBridge = () => patch(teamIntegration, 'destinationFor', async () => null)
|
||||
|
||||
// ── 1. Never throws, never blocks ──────────────────────────────────────────
|
||||
|
||||
test('a bot that rejects the call does not stop the notification path', async () => {
|
||||
bridgeTo('999', ['team.member.joined'])
|
||||
patch(botInternalClient, 'teamNotify', async () => ({ ok: false, status: 503, error: 'bot responded 503' }))
|
||||
|
||||
const recipients = await teamNotify.memberJoined(TEAM, { count: 2 })
|
||||
assert.equal(recipients, 2, 'the push half still reported its recipients')
|
||||
assert.equal(pushed.length, 1)
|
||||
})
|
||||
|
||||
test('a config lookup that throws is a bridge that sends nothing, not an exception', async () => {
|
||||
patch(teamIntegration, 'destinationFor', async () => { throw new Error('connection lost') })
|
||||
assert.equal(await teamBridge.deliver('team.member.joined', TEAM, { body: 'x' }), false)
|
||||
})
|
||||
|
||||
test('a client that throws outright is caught', async () => {
|
||||
bridgeTo('999', ['team.member.joined'])
|
||||
patch(botInternalClient, 'teamNotify', async () => { throw new Error('socket hang up') })
|
||||
assert.equal(await teamBridge.deliver('team.member.joined', TEAM, { body: 'x' }), false)
|
||||
})
|
||||
|
||||
test('a team with no id is refused before anything is looked up', async () => {
|
||||
patch(teamIntegration, 'destinationFor', async () => { throw new Error('should not be reached') })
|
||||
assert.equal(await teamBridge.deliver('team.member.joined', null, {}), false)
|
||||
assert.equal(await teamBridge.deliver('team.member.joined', {}, {}), false)
|
||||
})
|
||||
|
||||
// ── 2. The author exclusion stops at the channel ───────────────────────────
|
||||
|
||||
test('a forum post excludes its author from push but still bridges to the channel', async () => {
|
||||
bridgeTo('999', ['team.forum.post'])
|
||||
const seen = []
|
||||
patch(notifyDb, 'recipientIds', async (teamId, opts) => { seen.push(opts.exclude); return [12] })
|
||||
|
||||
const result = await teamNotify.forumPost({
|
||||
team: TEAM,
|
||||
threadId: 41,
|
||||
threadTitle: 'Siege tonight',
|
||||
type: 'discussion',
|
||||
authorUserId: 11,
|
||||
authorName: 'ana',
|
||||
bodyHtml: '<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> & 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')
|
||||
})
|
||||
291
server/test/teamIntegration.test.js
Normal file
291
server/test/teamIntegration.test.js
Normal 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, [])
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user