// ── The Team read model ──────────────────────────────────────────────────── // // What the three API tiers are allowed to see (TEAMS.md §2.11), assembled from // the projection, the resolver and the sync state. // // **Two rules shape every function here.** // // 1. *Hidden means absent from every public surface* (§2.8.3) — the index, the // lookup, the roster. Not archived, not deleted, and completely functional for // its own members. A hidden Team that 404s publicly but answers for a member // is the intended behaviour, not an inconsistency. // // 2. *Staleness is surfaced, never silent* (§2.4). Every public payload carries // `{ stale, lastSyncAt }`, so a page can say "roster last confirmed 14 minutes // ago" rather than presenting a stale roster as current. A projection nobody // can tell is stale is worse than one that is obviously old. // // The per-audience FIELD projection of a roster row is the module's, not core's // (§10.5, §3.3) — the visibility framework and its config are module-owned. This // phase serves a conservative core projection: a public roster carries in-game // display names and never a site account id or a game member key. The module's // rung-aware projection lands with the Team pages in phase 3. const teamsDb = require('./teams.db') const teamProvider = require('./teamProvider') const access = require('./teamAccess.model') const teamSync = require('./teamSync.model') // Past this multiple of the poll interval a projection is reported stale. Two // intervals rather than one, so an ordinary late poll does not make every page // cry wolf — the threshold has to mean "something is wrong", not "a run is due". const STALE_INTERVALS = 2 /** The public shape of a Team. Deliberately small. */ function publicTeam(row) { return { slug: row.slug, // What is DISPLAYED may have been overridden by staff; what the row IS never // changes (§2.2, §2.8.3). Public callers only ever see the former. name: row.display_name_override || row.name, abbr: row.abbr, memberCount: row.member_count, linkedCount: row.linked_count, onlineCount: row.online_count, meta: row.meta ?? null, status: row.status, createdAt: row.created_at, rosterSyncedAt: row.roster_synced_at, ...(row.status === 'archived' ? { archivedAt: row.archived_at, archivedReason: row.archived_reason } : {}), } } /** * The public shape of a roster row. * * `member_key` and `user_id` are both withheld: the first is a game-internal * identifier and the second names a site account. `linked` answers the only * question a public page has — whether this character has an account behind it — * without publishing which one. */ function publicMember(row) { return { displayName: row.display_name, rankLabel: row.rank_label, isLeader: Boolean(row.is_leader), online: Boolean(row.online), linked: row.user_id != null, } } /** The admin shape: everything, including what a decision overrode. */ function adminTeam(row) { return { id: row.id, moduleId: row.module_id, externalId: row.external_id, slug: row.slug, name: row.name, displayName: row.display_name_override || row.name, displayNameOverride: row.display_name_override, abbr: row.abbr, status: row.status, hidden: Boolean(row.hidden), hiddenReason: row.hidden_reason, hiddenTerm: row.hidden_term, nameReviewedAt: row.name_reviewed_at, memberCount: row.member_count, linkedCount: row.linked_count, onlineCount: row.online_count, rosterSyncedAt: row.roster_synced_at, membersEmptySince: row.members_empty_since, succeededBy: row.succeeded_by, createdAt: row.created_at, archivedAt: row.archived_at, archivedReason: row.archived_reason, meta: row.meta ?? null, } } function adminMember(row) { return { memberKey: row.member_key, displayName: row.display_name, userId: row.user_id, rankLabel: row.rank_label, isLeader: Boolean(row.is_leader), isLeaderSynced: Boolean(row.is_leader_synced), leaderOverride: row.leader_override || null, online: Boolean(row.online), status: row.status, firstSeenAt: row.first_seen_at, lastSeenAt: row.last_seen_at, departedAt: row.departed_at, } } /** * Freshness, as every public payload reports it. * * With no provider registered there is nothing to be stale ABOUT, so this reports * `stale: false` and a null timestamp rather than "very stale" — a deployment * with no game module is not a broken one. */ async function syncStatus() { const moduleId = teamProvider.providerModuleId() if (!moduleId) return { stale: false, lastSyncAt: null, configured: false } const [state, intervalS] = await Promise.all([ teamsDb.syncState(moduleId), teamSync.intervalSeconds(), ]) const lastSyncAt = state ? state.last_success_at : null const ageS = lastSyncAt ? (Date.now() - new Date(lastSyncAt).getTime()) / 1000 : Infinity return { configured: true, lastSyncAt, // Never synced at all is stale: a page must not present an empty projection // as a confirmed empty shard. stale: ageS > intervalS * STALE_INTERVALS, consecutiveFailures: state ? state.consecutive_failures : 0, } } // ── Public ───────────────────────────────────────────────────────────────── async function listPublic({ limit = 50, offset = 0 } = {}) { // Every active Team, not just the registered provider's. The rows are core's // and they outlive the module that filled them: keying the index on a provider // made an uninstalled module's Teams vanish from /teams while // /teams/:slug/members still served them in full, because the lookup goes by // slug. `configured: false` is how a client learns the projection is no longer // being maintained -- an empty list would have said something untrue instead. const [rows, sync] = await Promise.all([teamsDb.allActive(), syncStatus()]) const visible = rows.filter((r) => !r.hidden) return { teams: visible.slice(offset, offset + limit).map(publicTeam), total: visible.length, ...sync, // What the `teams` nav feature flag resolves from (§3.5). True if a provider // is registered OR any Team exists — the second half matters because Team // rows outlive the module that filled them, and hiding the nav entry the // moment a module is uninstalled would make every existing Team page // unreachable from the site while still answering by URL. // // False only when there is nothing and no prospect of anything, which is // exactly the bare-core case the flag exists for: a link to a permanently // empty page is worse than no link. enabled: Boolean(sync.configured) || visible.length > 0, } } /** * One Team by slug, for a public caller. * * An ARCHIVED Team resolves rather than 404ing (§2.2): a bookmark or a Discord * link from before a rename must land somewhere that explains itself. A HIDDEN * one does not resolve at all — that is the difference between retired and * suppressed. */ async function getPublic(slug) { const row = await teamsDb.findBySlug(slug) if (!row || row.hidden) return null const sync = await syncStatus() const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null return { ...publicTeam(row), // The three props the `team.overview` extension slot is declared with // (§3.4). A module's slot component runs in the browser and has to know // WHICH Team it is looking at, in its own vocabulary — `slug` is core's name // for it and resolves nothing on the module's side. // // On this route only, deliberately: the index has no slot and would // otherwise publish a module-internal identifier per row for nothing. None // of the three names a person — they are a core row id, a game-side group // id and a module name, and the identifiers §3.2 withholds (member keys, // site account ids) are not among them. id: row.id, externalId: row.external_id, moduleId: row.module_id, ...sync, successor: successor && !successor.hidden ? { slug: successor.slug, name: successor.display_name_override || successor.name } : null, } } /** * A Team's roster, projected for the caller's audience rung (§3.3). * * The ROW filter is the module's: it owns the visibility framework and its * configuration (§10.5), and core does not know what a rung is. The FIELD shape * stays core's — every row that survives goes through `publicMember`, which * withholds the member key and the user id whatever the module answers. So a * module can narrow what is published and cannot widen it, and core's "neither is * published" guarantee does not rest on every module's good behaviour. * * **A module that HAS a rung system and cannot answer withholds the roster.** That * is the one Team call where a refusal is not staleness: leaving a visibility * answer "alone" would publish the very rows the rungs exist to withhold. A * deployment with no module, or one whose module does not project at all, is a * different case entirely — nothing is being withheld there, so the roster is * served whole at core's public shape (`projects: false`). */ /** * One Team named the way the MODULE names it (§3.4 as amended). * * The lookup a module's page needs. A module holds its own identity for a Team — * a ServUO guild serial — and never core's row id or slug, deliberately: core's * identifiers are core-internal (§10.3), and handing them out is how a module * ends up storing them and then depending on them. * * Scoped to the naming module's OWN Teams. `module_id` comes from the path and is * matched, not trusted: it cannot be used to read another module's Team, which * matters because `external_id` is only unique within a module. */ async function getPublicByExternalId(moduleId, externalId) { const row = await teamsDb.findActive(moduleId, externalId) if (!row || row.hidden) return null return { ...publicTeam(row), id: row.id, externalId: row.external_id, moduleId: row.module_id, ...(await syncStatus()) } } async function rosterPublic(slug, viewer = null) { const row = await teamsDb.findBySlug(slug) if (!row || row.hidden) return null const [members, sync] = await Promise.all([ access.rosterWithOverrides(row.id), syncStatus(), ]) // The module gets the rows as it supplied them — this is its own data coming // home — plus who is asking, which is all a rung decision needs. const answer = await teamProvider.projectRoster(row.external_id, members, viewer) let visible if (answer.ok) visible = members.filter((m) => answer.members.includes(m.member_key)) else if (answer.projects) visible = [] // fail closed: it has rungs and we could not ask else visible = members // nothing to fail closed ABOUT return { members: visible.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at, // Stated rather than implied. An empty roster has three quite different // causes — a Team with no members, a rung that shows none, and a module that // could not be asked — and a page that cannot tell them apart will report the // last one as the first. projected: answer.ok, ...(answer.ok || !answer.projects ? {} : { projectionUnavailable: true }), } } // ── Player ───────────────────────────────────────────────────────────────── /** * The caller's Teams — membership and grants — each with the REASON it is listed. * * The two are read from their own tables and merged here rather than by a query * that unions them, so the reason survives into the payload. `both` is a real * state and the UI needs it: a member who also holds a historical grant should * see membership as the current reason without the grant vanishing. * * A hidden Team IS listed here. Suppression is a public-surface rule; a member is * not a member of the public. */ async function listForUser(userId) { const memberships = await teamsDb.activeTeamsForUser(userId) const byId = new Map() for (const row of memberships) { byId.set(row.id, { ...publicTeam(row), reason: 'membership', isLeader: Boolean(row.is_leader) }) } // Grants are per Team, so the visible set is walked rather than queried the // other way round; the population is small (a user's Teams), and it keeps path // 3's read on path 3's table. const all = await teamsDb.allActive() for (const row of all) { // eslint-disable-next-line no-await-in-loop const resolved = await access.forumAccess(row.id, userId) if (!resolved.viaGrant) continue const existing = byId.get(row.id) if (existing) existing.reason = 'both' else byId.set(row.id, { ...publicTeam(row), reason: 'grant', isLeader: false }) } return { teams: [...byId.values()], ...(await syncStatus()) } } /** The caller's own resolved access on one Team. */ async function accessForUser(slug, userId) { const row = await teamsDb.findBySlug(slug) if (!row) return null const resolved = await access.forumAccess(row.id, userId) return { slug: row.slug, ...resolved } } // ── Admin ────────────────────────────────────────────────────────────────── async function listAdmin({ includeArchived = false } = {}) { const moduleId = teamProvider.providerModuleId() const rows = await teamsDb.allActive() const sync = await syncStatus() const state = moduleId ? await teamsDb.syncState(moduleId) : null return { teams: rows.map(adminTeam), ...sync, // Shown verbatim on Admin → Teams, including the last error: an operator // debugging a stale projection needs what the provider actually said. syncState: state ? { moduleId: state.module_id, lastAttemptAt: state.last_attempt_at, lastSuccessAt: state.last_success_at, consecutiveFailures: state.consecutive_failures, lastError: state.last_error, pendingEmptySince: state.pending_empty_since, } : null, includeArchived, } } async function getAdmin(id) { const row = await teamsDb.findById(id) if (!row) return null const [members, grants, pending] = await Promise.all([ access.rosterWithOverrides(row.id, { includeDeparted: true }), access.grantLedger(row.id), // eslint-disable-next-line global-require require('./teamModeration.model').pendingForTeam(row.id), ]) return { ...adminTeam(row), members: members.map(adminMember), grants, pendingRequests: pending, } } module.exports = { listPublic, getPublic, getPublicByExternalId, rosterPublic, listForUser, accessForUser, listAdmin, getAdmin, syncStatus, publicTeam, publicMember, adminTeam, adminMember, STALE_INTERVALS, }