feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
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

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:
2026-08-18 20:25:30 -05:00
parent 46f43a5fd6
commit 11b4368b57
25 changed files with 2567 additions and 11 deletions

View File

@@ -0,0 +1,281 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import {
eventLabel, rowKey, isDefaultRow, blankDraft, draftFrom, appliesToLabel, toggleEvent,
setChannel, needsAcknowledgement, membersOnlyIdsOf, availableTargets,
} from '../../../lib/teamIntegrations.js'
// The Team notification bridge (TEAMS.md §7.2, phase 8).
//
// Named for the TEAM concern rather than for Discord, and placed under Teams
// rather than in the Discord Bot panel, because phase 10 replaces "Discord" here
// with whatever the capability registry declares. What changes then should be
// what fills this panel, not where an operator goes to find it. Nothing below
// hardcodes the word except the heading the server sends as `platform`.
//
// **The checkbox in the dialog is not the gate.** The server refuses to enable a
// row carrying `team.forum.post` or `team.announcement` without the
// acknowledgement, 422, whether or not this dialog was ever rendered — the same
// division TeamForumSettings draws for image uploads. What is here is how the
// gate is PRESENTED: the sentence an operator agrees to, and the fact that
// agreeing is a deliberate act rather than a checkbox they tab past.
const ACK_TEXT = [
'Forum posts and announcements are visible only to a Teams members. This site cannot see who can'
+ ' read a channel on another platform, so it cannot check that for you.',
'By enabling these events you confirm that the destination channel is restricted to the members of'
+ ' the Team whose posts it will carry.',
]
export default function TeamIntegrations() {
const [config, setConfig] = useState(null)
const [teams, setTeams] = useState([])
const [draft, setDraft] = useState(null)
const [dialog, setDialog] = useState(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError('')
try {
const [cfg, teamList] = await Promise.all([api.admin.teamIntegrations(), api.admin.listTeams()])
setConfig(cfg)
setTeams((teamList.teams || []).filter((t) => t.status === 'active'))
} catch (err) {
// A moderator never reaches this panel — the admin nav does not render it —
// so a 403 here means the role changed underneath an open tab rather than a
// routing mistake, and saying so beats "could not load".
setError(err.status === 403 ? 'Only an admin can configure the notification bridge.' : (err.message || 'Could not load the bridge configuration.'))
}
}, [])
useEffect(() => { load() }, [load])
if (!config) {
return (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
const membersOnlyIds = membersOnlyIdsOf(config.events)
const { hasDefault, teams: available } = availableTargets(config.rows, teams)
async function persist(next) {
setBusy(true)
setError('')
setNotice('')
try {
await api.admin.saveTeamIntegration({
teamId: next.teamId,
events: next.events,
channelRef: next.channelRef.trim() || null,
enabled: next.enabled,
membersAck: next.membersAck,
})
setDraft(null)
setDialog(null)
setNotice('Saved.')
await load()
} catch (err) {
setError(err.message || 'Could not save.')
setDialog(null)
} finally {
setBusy(false)
}
}
// Enabling members-only events without a standing acknowledgement asks first.
// Everything else — disabling, editing a channel, adding a roster event — saves
// straight through.
function save() {
if (!draft) return
if (needsAcknowledgement(draft, membersOnlyIds)) {
setDialog(draft)
return
}
persist(draft)
}
async function remove(row) {
setBusy(true)
setError('')
try {
await api.admin.deleteTeamIntegration(row.team_id ?? null)
setNotice('Removed.')
await load()
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
override it for individual Teams. A message is sent once and not retried the bridge is a
courtesy, and nothing on the site depends on it arriving.
</p>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
{config.rows.length === 0 && !draft && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured no Team events leave the site.</p>
)}
{config.rows.length > 0 && (
<table className="table">
<thead>
<tr><th>Applies to</th><th>Events</th><th>Channel</th><th>State</th><th /></tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={rowKey(row)}>
<td>
{appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block' }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{!draft && (
<div style={{ marginTop: 12 }}>
{!hasDefault && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(null))}>
Set a default for all Teams
</button>
)}
{available.length > 0 && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(available[0].id))}>
Add a per-Team override
</button>
)}
</div>
)}
{draft && (
<div style={{ marginTop: 18, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Applies to</span>
<select
className="select"
value={draft.teamId === null ? 'default' : String(draft.teamId)}
onChange={(e) => setDraft({ ...draft, teamId: e.target.value === 'default' ? null : Number(e.target.value) })}
>
<option value="default">All Teams (default)</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.display_name_override || t.name}</option>
))}
</select>
</label>
<span className="field-label">Events to send</span>
{config.events.map((event) => (
<label key={event.id} style={{ display: 'block', marginTop: 6 }}>
<input
type="checkbox"
checked={draft.events.includes(event.id)}
onChange={() => setDraft((d) => toggleEvent(d, event.id))}
style={{ marginRight: 8 }}
/>
<span className="sans" style={{ fontSize: '0.82rem' }}>{eventLabel(event.id)}</span>
{event.membersOnly && (
<span className="dim sans" style={{ fontSize: '0.72rem', marginLeft: 8 }}>members-only content</span>
)}
</label>
))}
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Channel id</span>
<input
className="input"
value={draft.channelRef}
// Changing the channel drops a standing acknowledgement in the SAME
// place the server does. Leaving the tick showing while the server
// has already decided to clear it would let an operator repoint a row
// at a public channel and believe the confirmation still covered it.
onChange={(e) => setDraft((d) => setChannel(d, e.target.value))}
placeholder="1024839201048392010"
style={{ maxWidth: 280 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
Right-click a channel in {config.platform} and copy its id. Changing it asks you to confirm
the new channels audience again.
</span>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
style={{ marginRight: 8 }}
/>
<span className="field-label" style={{ display: 'inline' }}>Enabled</span>
</label>
{draft.membersAck && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
You have confirmed this channel is restricted to the Teams members.{' '}
<button type="button" className="btn-ghost" onClick={() => setDraft({ ...draft, membersAck: false })}>
Withdraw
</button>
</p>
)}
<div style={{ marginTop: 16 }}>
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
</div>
</div>
)}
{dialog && (
<div style={{ marginTop: 18, border: '1px solid #e0b877', padding: 14, borderRadius: 4 }}>
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destinations audience</h3>
{ACK_TEXT.map((line) => (
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
))}
<button
type="button"
className="btn"
disabled={busy}
onClick={() => persist({ ...dialog, membersAck: true })}
>
I confirm the channel is members-only
</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
</div>
)}
</section>
)
}

View File

@@ -6,6 +6,7 @@ import {
} from '../../../lib/teamAdmin.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
import TeamIntegrations from './TeamIntegrations.jsx'
// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
//
@@ -341,6 +342,11 @@ export default function TeamsAdmin() {
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
{/* Admin-only, matching the server (§7.2). Rendered for a moderator it would
be a panel every action in fails 403 — the role gate is the server's, and
this is only how the screen agrees with it. */}
{role === 'admin' && <TeamIntegrations />}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />