// What Admin → Teams → Notification bridge decides (TEAMS.md §7.2, phase 8). // // The view is a form; these are the rules it applies, extracted for the same // reason `teamAdmin.js` is: the interesting parts are decisions — when the // acknowledgement dialog opens, and when a standing acknowledgement stops being // valid — and a decision embedded in JSX is one nothing can assert on. // // **The rules here MIRROR the server's and do not replace them.** The server // refuses to enable a members-only bridge without the acknowledgement (422) // whether or not this file ever ran. What is here is so the screen agrees with // that answer before making the round trip, rather than showing an operator a // save that fails for a reason the form did not mention. // Wording an operator reads, per event id the server offers. Presentation, so it // lives on this side; the one bit that is policy — which events are members-only — // comes from the server with each event. export const EVENT_LABELS = { 'team.member.joined': 'New members joined', 'team.leadership.changed': 'Leadership changed', 'team.forum.post': 'New forum post', 'team.announcement': 'Announcement posted', } export const eventLabel = (id) => EVENT_LABELS[id] || id /** A row's identity in a list. `null` and `undefined` are both the default row. */ export const rowKey = (row) => (row.team_id === null || row.team_id === undefined ? 'default' : String(row.team_id)) export const isDefaultRow = (row) => row.team_id === null || row.team_id === undefined export const blankDraft = (teamId = null) => ({ teamId, events: [], channelRef: '', enabled: false, membersAck: false, }) export const draftFrom = (row) => ({ teamId: row.team_id ?? null, events: row.events || [], channelRef: row.channel_ref || '', enabled: !!row.enabled, membersAck: !!row.members_ack, }) export function appliesToLabel(row, fallback = 'All Teams') { if (isDefaultRow(row)) return fallback return row.display_name_override || row.team_name || `Team #${row.team_id}` } /** Toggle one event in a draft, preserving order of first selection. */ export const toggleEvent = (draft, id) => ({ ...draft, events: draft.events.includes(id) ? draft.events.filter((e) => e !== id) : [...draft.events, id], }) /** * Repointing the row drops a standing acknowledgement, in the SAME place the * server does. * * Leaving the tick showing while the server has already decided to clear it is * the one way this screen could actively mislead: an operator repoints a row at a * public channel, sees "members-only destination confirmed" still ticked, and * believes the confirmation they gave for a private channel covers the new one. */ export function setChannel(draft, channelRef) { if (channelRef === draft.channelRef) return draft return { ...draft, channelRef, membersAck: false } } /** Does this draft carry anything that would publish members-only text? */ export const carriesMembersOnly = (draft, membersOnlyIds) => draft.events.some((id) => membersOnlyIds.includes(id)) /** * Should saving stop and ask first? * * Only when ENABLING. A draft that carries forum events but is switched off is a * configuration being written, not a channel being published to — asking then * would make an operator confirm something they have not decided to do yet, which * is how a confirmation dialog becomes a thing people click through. */ export const needsAcknowledgement = (draft, membersOnlyIds) => !!draft.enabled && carriesMembersOnly(draft, membersOnlyIds) && !draft.membersAck /** The ids of every event the server flagged as members-only. */ export const membersOnlyIdsOf = (events) => (events || []).filter((e) => e.membersOnly).map((e) => e.id) /** * Which Teams may still be given an override, and whether the default is taken. * * Offering a Team that already has a row would only produce a save that silently * overwrote it, since the unique key is (platform, team). */ export function availableTargets(rows, teams) { const taken = new Set(rows.filter((r) => !isDefaultRow(r)).map((r) => r.team_id)) return { hasDefault: rows.some(isDefaultRow), teams: (teams || []).filter((t) => t.status === 'active' && !taken.has(t.id)), } }