Files
website/server/src/model/teams/teamIntegration.db.js
wtclaude 11b4368b57
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 10m49s
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>
2026-08-18 20:25:30 -05:00

123 lines
4.5 KiB
JavaScript

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