feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
The same Team event as §6, delivered a third time: push, email, and now a Discord channel the operator configured. Not a second pipeline — teamNotify.js already computed the recipient set once, so the bridge is a sink beside the two that were there. The design's gate has no data source. §7.2 bridges an event only if "its visibility is public, or its destination channel is configured for a members-only Team context". The four team.* streams carry no visibility; forum threads have no public/members column because a forum is members-only by construction; and core cannot see a Discord channel's permissions. So §7.2's own example config names exactly the two events that are never public. The gate is therefore an attributed operator acknowledgement, in the shape teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet drop at delivery — it is re-asked at delivery as well as at the save, and changing the channel clears it, because an acknowledgement is about a destination and cannot survive the destination changing underneath it. The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every override overrides — is unrepresentable. Proved on a real MariaDB (error 1048). Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the unique key, and the foreign key the original had no room for. One-shot, not queued: "identical to announce and mod-reverse" names two different reliability models, and a Team notification is the moment it describes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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