// ── SQL for first-party clans ───────────────────────────────────────────── // // Three tables (see `schema.sql`): the clans a board carried, their members, and // what this module knows about each server's board. Raw parameterised SQL, as // everywhere in this module; the model decides what any of it means. const core = require('../../core') const CLANS = 'rust_clans' const MEMBERS = 'rust_clan_members' const BOARDS = 'rust_clan_boards' const LINKS = 'rust_account_links' const PLAYERS = 'rust_players' const SERVERS = 'rust_servers' // ── Boards ───────────────────────────────────────────────────────────────── /** One server's board record, or null when it has never sent one. */ async function getBoard(serverId) { const rows = await core.query( `SELECT server_id AS serverId, board_t AS boardT, seen_at AS seenAt, enabled, supported, truncated, backend, reason, umod_clans AS umodClans, clan_count AS clanCount FROM ${BOARDS} WHERE server_id = ?`, [serverId], ) return rows[0] || null } /** Every configured server beside its board record, which may be absent. */ async function listBoards() { return core.query( `SELECT s.id AS serverId, s.name AS serverName, s.enabled AS serverEnabled, b.board_t AS boardT, b.seen_at AS seenAt, b.enabled, b.supported, b.truncated, b.backend, b.reason, b.umod_clans AS umodClans, b.clan_count AS clanCount FROM ${SERVERS} s LEFT JOIN ${BOARDS} b ON b.server_id = s.id ORDER BY s.sort_order ASC, s.id ASC`, ) } /** * Records what a board said about itself. * * `seenAt` is passed only when the board's `t` ADVANCED, and is then the * website's own now; otherwise the stored one is kept. That is the whole of the * freshness rule (see `schema.sql`), so it is done in SQL rather than trusted to * every caller to read-then-write. */ async function putBoard({ serverId, boardT, advanced, enabled, supported, truncated, backend, reason, umodClans, clanCount }) { await core.query( `INSERT INTO ${BOARDS} (server_id, board_t, seen_at, enabled, supported, truncated, backend, reason, umod_clans, clan_count, updated_at) VALUES (?, ?, ${advanced ? 'CURRENT_TIMESTAMP' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE board_t = VALUES(board_t), seen_at = ${advanced ? 'CURRENT_TIMESTAMP' : 'seen_at'}, enabled = VALUES(enabled), supported = VALUES(supported), truncated = VALUES(truncated), backend = VALUES(backend), reason = VALUES(reason), umod_clans = VALUES(umod_clans), clan_count = VALUES(clan_count), updated_at = CURRENT_TIMESTAMP`, [ serverId, boardT, enabled ? 1 : 0, supported ? 1 : 0, truncated ? 1 : 0, backend || null, reason ? String(reason).slice(0, 255) : null, umodClans ? 1 : 0, clanCount || 0, ], ) } // ── Clans ────────────────────────────────────────────────────────────────── /** Every clan this module holds for one server, gone or not. */ async function listClansForServer(serverId) { return core.query( `SELECT external_id AS externalId, clan_id AS clanId, created_ms AS createdMs, name, member_count AS memberCount, gone_at AS goneAt FROM ${CLANS} WHERE server_id = ?`, [serverId], ) } /** * Every member of one server's current clans, as the board last stated them, * for diffing the next board against. The name is the BOARD's, not the player * table's, because it is compared with the board. */ async function listMembersForServer(serverId) { return core.query( `SELECT m.external_id AS externalId, m.steam_id AS steamId, m.role_rank AS rank, m.role_name AS role, m.name FROM ${MEMBERS} m JOIN ${CLANS} c ON c.external_id = m.external_id WHERE c.server_id = ? AND c.gone_at IS NULL`, [serverId], ) } async function upsertClan({ externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers }) { await core.query( `INSERT INTO ${CLANS} (external_id, server_id, clan_id, created_ms, name, color, score, member_count, max_members, first_seen, updated_at, gone_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL) ON DUPLICATE KEY UPDATE name = VALUES(name), color = VALUES(color), score = VALUES(score), member_count = VALUES(member_count), max_members = VALUES(max_members), updated_at = CURRENT_TIMESTAMP, gone_at = NULL`, [externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers], ) } /** * Replaces one clan's members. * * Delete then insert, not wrapped in a transaction — the same trade the presence * board makes (`events.db.replacePresence`): a fraction of a second in which a * roster read might come back short, against holding a lock on a table that core's * reconciler and two public routes read. */ async function replaceMembers(externalId, members) { await core.query(`DELETE FROM ${MEMBERS} WHERE external_id = ?`, [externalId]) for (const m of members) { // eslint-disable-next-line no-await-in-loop await core.query( `INSERT INTO ${MEMBERS} (external_id, steam_id, name, role_rank, role_name, joined_ms) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), role_rank = VALUES(role_rank), role_name = VALUES(role_name), joined_ms = VALUES(joined_ms)`, [externalId, m.steamId, m.name, m.rank, m.role, m.joinedMs], ) } } /** Marks clans gone. Their members are removed with them; a gone clan has no roster. */ async function markGone(externalIds) { if (!externalIds.length) return const marks = externalIds.map(() => '?').join(', ') await core.query( `UPDATE ${CLANS} SET gone_at = CURRENT_TIMESTAMP WHERE external_id IN (${marks}) AND gone_at IS NULL`, externalIds, ) await core.query(`DELETE FROM ${MEMBERS} WHERE external_id IN (${marks})`, externalIds) } /** One clan by its Team identity, with its server's name, or null. */ async function findClan(externalId) { const rows = await core.query( `SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName, c.clan_id AS clanId, c.created_ms AS createdMs, c.name, c.color, c.score, c.member_count AS memberCount, c.max_members AS maxMembers, c.first_seen AS firstSeen, c.updated_at AS updatedAt, c.gone_at AS goneAt FROM ${CLANS} c JOIN ${SERVERS} s ON s.id = c.server_id WHERE c.external_id = ?`, [externalId], ) return rows[0] || null } /** * The newest clan this module holds under a game id on one server, or null. * * The fallback for the one event that can arrive without a creation time * (`clan.member.added`, when the plugin could not read the clan back). Newest, * because an id that the game has re-used belongs to the clan that re-used it. */ async function findByGameId(serverId, clanId) { const rows = await core.query( `SELECT external_id AS externalId, name FROM ${CLANS} WHERE server_id = ? AND clan_id = ? ORDER BY created_ms DESC LIMIT 1`, [serverId, clanId], ) return rows[0] || null } /** Every clan still on a board, for core's `getTeams`. */ async function listActiveClans() { return core.query( `SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName, c.name, c.color, c.score, c.member_count AS memberCount FROM ${CLANS} c JOIN ${SERVERS} s ON s.id = c.server_id WHERE c.gone_at IS NULL ORDER BY c.server_id ASC, c.score DESC, c.name ASC`, ) } /** One server's clans still on its board, for the public Clans tab. Best first. */ async function listPublicForServer(serverId) { return core.query( `SELECT external_id AS externalId, name, color, score, member_count AS memberCount, max_members AS maxMembers FROM ${CLANS} WHERE server_id = ? AND gone_at IS NULL ORDER BY score DESC, name ASC`, [serverId], ) } /** * One clan's roster, with the website account behind each member when there is * one and whether they are on the clan's server right now. * * Three joins, all of this module's own tables: the link (a Steam id to a user), * the player table (the newest name the game has sent for them) and the presence * board. Presence is joined on the CLAN's server — a member on another server of * the fleet is not online here. */ async function listMembers(externalId) { return core.query( `SELECT m.steam_id AS steamId, COALESCE(p.name, m.name) AS name, m.role_rank AS rank, m.role_name AS role, m.joined_ms AS joinedMs, l.user_id AS userId, (pr.steam_id IS NOT NULL) AS online FROM ${MEMBERS} m JOIN ${CLANS} c ON c.external_id = m.external_id LEFT JOIN ${LINKS} l ON l.steam_id = m.steam_id LEFT JOIN ${PLAYERS} p ON p.steam_id = m.steam_id LEFT JOIN rust_presence pr ON pr.server_id = c.server_id AND pr.steam_id = m.steam_id WHERE m.external_id = ? ORDER BY (m.role_rank IS NULL) ASC, m.role_rank ASC, name ASC`, [externalId], ) } /** Whether a website user holds a linked Steam account that is a member of this clan. */ async function userIsMember(externalId, userId) { const rows = await core.query( `SELECT 1 AS yes FROM ${MEMBERS} m JOIN ${LINKS} l ON l.steam_id = m.steam_id WHERE m.external_id = ? AND l.user_id = ? LIMIT 1`, [externalId, userId], ) return rows.length > 0 } /** * Recent clan events for one server, oldest first, for re-offering their feed * items to core until the Team they name exists (see `model/clans`). */ async function recentClanEvents(serverId, sinceMs) { return core.query( `SELECT id, kind, t, raw FROM rust_events WHERE server_id = ? AND kind LIKE 'clan.%' AND t >= ? ORDER BY t ASC, id ASC LIMIT 200`, [serverId, sinceMs], ) } /** * Notes a player's name WITHOUT touching `last_seen`. * * `events.db.touchPlayer` also moves `last_seen`, which is right for a frame that * says a player was on and wrong for a clan frame: a kick is done TO somebody who * may be offline, and a leaderboard's "last seen" would then read as a presence * signal for a player who never connected (PLAN.md §23). * * A player this module has never heard of still gets a on the new row, * because the column is NOT NULL; what matters is that an existing row's is left * alone, and every surface that reads it is behind the presence gate anyway. */ async function rememberName(steamId, name) { if (!steamId) return await core.query( `INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE name = COALESCE(VALUES(name), name)`, [steamId, name || null], ) } module.exports = { getBoard, listBoards, putBoard, listClansForServer, listMembersForServer, upsertClan, replaceMembers, markGone, findClan, findByGameId, listActiveClans, listPublicForServer, listMembers, userIsMember, recentClanEvents, rememberName, }