const { query } = require('../../core') const COLS = 'account, user_id, char_name, linked_at' // Upsert a link. account is the PK, so a re-link moves the account to the new // user (the sidecar already treats /link/confirm as authoritative). async function upsert({ account, userId, charName }) { await query( `INSERT INTO shard_account_links (account, user_id, char_name) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`, [account, userId, charName || null], ) return getByAccount(account) } async function getByAccount(account) { const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account]) return rows[0] || null } const listByUser = (userId) => query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId]) async function isOwnedBy(account, userId) { const rows = await query( 'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1', [account, userId], ) return rows.length > 0 } const remove = (account, userId) => query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId]) // Drop the mirror for an account regardless of which user held it — used to // reconcile when the tie is severed at the source (an in-game [unlink → // account.unlinked event, or a site-side DELETE /link/{account}). const removeByAccount = (account) => query('DELETE FROM shard_account_links WHERE account = ?', [account]) // A bound on every "resolve a set of people" read below. It mirrors core's own // `MAX_AUDIENCE` (engagementRecipients.db.js) rather than importing it: a module // cannot reach into core's models, and the number this file has to respect is // "no more ids than core will accept" whatever core calls it. const MAX_AUDIENCE = 5000 // **Website user ids for a set of game accounts.** The bulk form of // `getByAccount`, and the one the engagement mapper needs: a guild event's // audience is its members, and turning a roster into a set of people is one join // rather than one query per member (Phase 11). // // DISTINCT because two characters on one guild roster can share an account, and // the caller wants people rather than characters. async function userIdsForAccounts(accounts) { const wanted = [...new Set((accounts || []).filter((a) => typeof a === 'string' && a))] if (!wanted.length) return [] const capped = wanted.slice(0, MAX_AUDIENCE) const marks = capped.map(() => '?').join(', ') const rows = await query( `SELECT DISTINCT user_id FROM shard_account_links WHERE account IN (${marks})`, capped, ) return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0) } // **Every website user with a linked game account** — the `uo.linked.accounts` // audience (ENGAGEMENT.md §5.1a). The set an operator reaches for first, and the // one a `not` composes against ("everyone who has NOT linked"). // // It returns ids and nothing else: §5.1a rule 2 is that a module's resolver // never sees an address, a channel or a template, and core maps ids to addresses // on its own side after preferences, suppression and the verification gate. async function allLinkedUserIds(limit = MAX_AUDIENCE) { const rows = await query( 'SELECT DISTINCT user_id FROM shard_account_links ORDER BY user_id LIMIT ?', [limit], ) return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0) } module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount, userIdsForAccounts, allLinkedUserIds, MAX_AUDIENCE, }