// SQL for the Team tables. Raw parameterised mariadb, no ORM, per the layered // backend convention (router → controller → model → db). // // This file holds statements only. Every decision about WHETHER to write — the // four refusal gates, the quarantine, the rename rule — lives in the models above // it, because a gate expressed as a WHERE clause is a gate nobody can find. const { query } = require('../../utils/db') // ── teams ────────────────────────────────────────────────────────────────── const TEAM_COLUMNS = ` id, module_id, external_id, name, abbr, slug, status, meta, member_count, linked_count, online_count, hidden, hidden_reason, hidden_term, name_reviewed_at, display_name_override, roster_synced_at, members_empty_since, succeeded_by, created_at, archived_at, archived_reason` /** Every ACTIVE team for a module — the set the reconciler diffs against. */ async function activeByModule(moduleId) { return query( `SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND status = 'active' ORDER BY id`, [moduleId], ) } /** * Every ACTIVE team, whichever module owns it. * * For the READ side, which must not be keyed on a provider being registered. The * rows are core's and they outlive the module that filled them — a module * uninstalled or disabled leaves a projection that is unmaintained, not one that * stopped existing. Listing by provider made `/teams` empty while * `/teams/:slug/members` still answered in full, since the lookup goes by slug: * the index denied a Team that direct URLs served. */ async function allActive() { return query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE status = 'active' ORDER BY id`) } /** The ACTIVE row for an external id, or undefined. At most one, by uq_teams_active. */ async function findActive(moduleId, externalId) { const rows = await query( `SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND external_id = ? AND status = 'active'`, [moduleId, externalId], ) return rows[0] } async function findById(id) { const rows = await query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE id = ?`, [id]) return rows[0] } /** By slug, ACTIVE or ARCHIVED — an archived Team stays reachable at its old slug (§2.2). */ async function findBySlug(slug) { const rows = await query( `SELECT ${TEAM_COLUMNS} FROM teams WHERE slug = ? ORDER BY (status = 'active') DESC, id DESC LIMIT 1`, [slug], ) return rows[0] } /** * Slugs already taken, ACTIVE OR ARCHIVED. * * The unique key only constrains active rows, and this deliberately checks more * than the key does: §2.2 promises an archived Team stays readable at its old * slug, and handing that slug to a new Team would silently break every bookmark * and Discord link pointing at the old one. */ async function slugsLike(base) { const rows = await query('SELECT slug FROM teams WHERE slug = ? OR slug LIKE ?', [base, `${base}-%`]) return rows.map((r) => r.slug) } async function insertTeam({ moduleId, externalId, name, abbr, slug, meta, hidden, hiddenReason, hiddenTerm }) { const res = await query( `INSERT INTO teams (module_id, external_id, name, abbr, slug, meta, hidden, hidden_reason, hidden_term) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [moduleId, externalId, name, abbr, slug, meta == null ? null : JSON.stringify(meta), hidden ? 1 : 0, hiddenReason || null, hiddenTerm || null], ) return res.insertId } /** Update the mutable fields. `name` and `slug` are absent by design — §2.2 freezes both. */ async function updateTeam(id, { abbr, meta }) { await query('UPDATE teams SET abbr = ?, meta = ? WHERE id = ?', [abbr, meta == null ? null : JSON.stringify(meta), id]) } async function archiveTeam(id, reason, succeededBy = null) { await query( `UPDATE teams SET status = 'archived', archived_at = NOW(), archived_reason = ?, succeeded_by = ? WHERE id = ? AND status = 'active'`, [reason, succeededBy, id], ) } /** * Recompute the three denormalised counts from the projection. * * Derived in one statement rather than incremented as rows change, so a missed * delta can never leave a count drifting from the table it summarises — the count * is only ever as wrong as the projection is. */ async function recount(teamId) { await query( `UPDATE teams t SET member_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active'), linked_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.user_id IS NOT NULL), online_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.online = 1) WHERE t.id = ?`, [teamId], ) } // ── team_members ─────────────────────────────────────────────────────────── const MEMBER_COLUMNS = ` team_id, member_key, display_name, user_id, is_leader, rank_label, online, status, first_seen_at, last_seen_at, departed_at` async function membersByTeam(teamId, { includeDeparted = false } = {}) { return query( `SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ?` + (includeDeparted ? '' : " AND status = 'active'") + ' ORDER BY is_leader DESC, display_name, member_key', [teamId], ) } async function memberKeys(teamId) { const rows = await query("SELECT member_key FROM team_members WHERE team_id = ? AND status = 'active'", [teamId]) return rows.map((r) => r.member_key) } async function findMember(teamId, memberKey) { const rows = await query(`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND member_key = ?`, [teamId, memberKey]) return rows[0] } /** The caller's ACTIVE membership of a team, or undefined. Path 1 of §2.5, and only path 1. */ async function activeByUser(teamId, userId) { const rows = await query( `SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'`, [teamId, userId], ) return rows[0] } /** Every ACTIVE membership a user holds, with the team joined on. */ async function activeTeamsForUser(userId) { return query( `SELECT ${TEAM_COLUMNS.split(',').map((c) => `t.${c.trim()}`).join(', ')}, m.member_key, m.is_leader, m.rank_label, m.display_name AS member_display_name FROM team_members m JOIN teams t ON t.id = m.team_id WHERE m.user_id = ? AND m.status = 'active' AND t.status = 'active' ORDER BY t.name`, [userId], ) } /** * Insert or refresh one member row. * * `first_seen_at` is never overwritten, so a member who leaves and rejoins keeps * the date they first appeared; `status` returns to active on the same statement, * which is what makes a rejoin a revived row rather than a second one. * * **`is_leader` is set on INSERT only, and deliberately not on update.** Path 2 of * §2.5 is answered by `getTeamLeaders()`, not by the roster — two writers for one * column is how a refused leadership answer turns into a silent demotion, because * the roster would already have written `leader: false` before the authoritative * call was even made. Seeding it on insert means a Team whose leadership call is * failing is not leaderless from the start; after that, only setLeaders() moves it. */ async function upsertMember({ teamId, memberKey, displayName, userId, isLeader, rankLabel, online }) { await query( `INSERT INTO team_members (team_id, member_key, display_name, user_id, is_leader, rank_label, online) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), user_id = VALUES(user_id), rank_label = VALUES(rank_label), online = VALUES(online), status = 'active', departed_at = NULL, last_seen_at = NOW()`, [teamId, memberKey, displayName, userId, isLeader ? 1 : 0, rankLabel, online ? 1 : 0], ) } /** Soft-depart the named members. Rows are kept so history and rejoins survive. */ async function markDeparted(teamId, memberKeys_) { if (!memberKeys_.length) return const holes = memberKeys_.map(() => '?').join(', ') await query( `UPDATE team_members SET status = 'departed', departed_at = NOW(), online = 0 WHERE team_id = ? AND status = 'active' AND member_key IN (${holes})`, [teamId, ...memberKeys_], ) } /** Set is_leader for a whole team in one pass — the sync's path-2 write. */ async function setLeaders(teamId, leaderKeys) { if (leaderKeys.length) { const holes = leaderKeys.map(() => '?').join(', ') await query( `UPDATE team_members SET is_leader = (member_key IN (${holes})) WHERE team_id = ?`, [...leaderKeys, teamId], ) } else { await query('UPDATE team_members SET is_leader = 0 WHERE team_id = ?', [teamId]) } } async function setMemberLeader(teamId, memberKey, isLeader) { await query('UPDATE team_members SET is_leader = ? WHERE team_id = ? AND member_key = ?', [isLeader ? 1 : 0, teamId, memberKey]) } // ── team_sync_state ──────────────────────────────────────────────────────── async function syncState(moduleId) { const rows = await query( `SELECT module_id, last_attempt_at, last_success_at, consecutive_failures, last_error, pending_empty_since FROM team_sync_state WHERE module_id = ?`, [moduleId], ) return rows[0] } async function recordAttempt(moduleId) { await query( `INSERT INTO team_sync_state (module_id, last_attempt_at) VALUES (?, NOW()) ON DUPLICATE KEY UPDATE last_attempt_at = NOW()`, [moduleId], ) } async function recordFailure(moduleId, error) { await query( `INSERT INTO team_sync_state (module_id, last_attempt_at, consecutive_failures, last_error) VALUES (?, NOW(), 1, ?) ON DUPLICATE KEY UPDATE last_attempt_at = NOW(), consecutive_failures = consecutive_failures + 1, last_error = VALUES(last_error)`, [moduleId, String(error || '').slice(0, 500)], ) } async function recordSuccess(moduleId) { await query( `INSERT INTO team_sync_state (module_id, last_attempt_at, last_success_at, consecutive_failures, last_error) VALUES (?, NOW(), NOW(), 0, NULL) ON DUPLICATE KEY UPDATE last_attempt_at = NOW(), last_success_at = NOW(), consecutive_failures = 0, last_error = NULL`, [moduleId], ) } /** Bumped only when a roster was actually APPLIED — never on a refused call. */ async function markRosterSynced(teamId) { await query('UPDATE teams SET roster_synced_at = NOW() WHERE id = ?', [teamId]) } /** §2.4 gate 4's per-Team quarantine. `since = null` clears it. */ async function setMembersEmptySince(teamId, since) { await query('UPDATE teams SET members_empty_since = ? WHERE id = ?', [since, teamId]) } /** The §2.4 gate-2 quarantine. `since = null` clears it. */ async function setPendingEmpty(moduleId, since) { await query( `INSERT INTO team_sync_state (module_id, pending_empty_since) VALUES (?, ?) ON DUPLICATE KEY UPDATE pending_empty_since = VALUES(pending_empty_since)`, [moduleId, since], ) } module.exports = { activeByModule, allActive, findActive, findById, findBySlug, slugsLike, insertTeam, updateTeam, archiveTeam, recount, markRosterSynced, setMembersEmptySince, membersByTeam, memberKeys, findMember, activeByUser, activeTeamsForUser, upsertMember, markDeparted, setLeaders, setMemberLeader, syncState, recordAttempt, recordFailure, recordSuccess, setPendingEmpty, }