// ── Admin: a single user's shard (uo-link) footprint ────────────────────────── // // Backs the /admin/users/:id detail page. Every read is scoped to the target // user's linked game accounts (from the local shard_account_links mirror): their // vendor sales, houses, and currently-online characters. The live character // rosters are fetched separately by the client through the existing admin-bypass // /admin/shard/* endpoints, so nothing here round-trips the sidecar — these are // fast, DB-backed reads. Admin-only (registered under adminOnly in the router). const users = require('../../../model/users/users.model') const shardLinks = require('../../../model/shardLinks/shardLinks.model') const shardState = require('../../../model/shardState/shardState.model') const uoLinkClient = require('../../../utils/uoLinkClient') const activity = require('../../../model/activity/activity.model') const { salesForAccounts } = require('../../../utils/shardSales') const log = require('../../../utils/logger')('admin-user-shard') // Resolve the target user's linked game accounts, or null if the user id is // unknown (so the handler can 404 rather than silently returning an empty set). async function accountsForUser(id) { const user = await users.getById(id) if (!user) return null const links = await shardLinks.listForUser(id) return { user, links, accounts: links.map((l) => l.account) } } // GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe). async function getUser(req, res) { try { const user = await users.getById(Number(req.params.id)) if (!user) return res.status(404).json({ message: 'Not found' }) return res.json(user) } catch (err) { log.error('getUser', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /admin/users/:id/shard/accounts — the user's linked game accounts. async function listAccounts(req, res) { try { const ctx = await accountsForUser(Number(req.params.id)) if (!ctx) return res.status(404).json({ message: 'Not found' }) return res.json(ctx.links) } catch (err) { log.error('listAccounts', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /admin/users/:id/shard/sales — recent vendor sales on the user's accounts. async function getSales(req, res) { try { const ctx = await accountsForUser(Number(req.params.id)) if (!ctx) return res.status(404).json({ message: 'Not found' }) return res.json(await salesForAccounts(ctx.accounts)) } catch (err) { log.error('getSales', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /admin/users/:id/shard/houses — houses owned by the user's accounts. async function getHouses(req, res) { try { const ctx = await accountsForUser(Number(req.params.id)) if (!ctx) return res.status(404).json({ message: 'Not found' }) return res.json(await shardState.listHousesForAccounts(ctx.accounts)) } catch (err) { log.error('getHouses', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /admin/users/:id/shard/online — the user's characters currently online. async function getOnline(req, res) { try { const ctx = await accountsForUser(Number(req.params.id)) if (!ctx) return res.status(404).json({ message: 'Not found' }) return res.json(await shardState.listOnlineForAccounts(ctx.accounts)) } catch (err) { log.error('getOnline', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links: // city governorships they currently hold and guilds they lead. Both are reliable // current-state lookups on the user's linked accounts. async function getStanding(req, res) { try { const ctx = await accountsForUser(Number(req.params.id)) if (!ctx) return res.status(404).json({ message: 'Not found' }) const [governorOf, guildsLed] = await Promise.all([ shardState.listGovernorshipsForAccounts(ctx.accounts), shardState.listGuildsLedForAccounts(ctx.accounts), ]) return res.json({ governorOf, guildsLed }) } catch (err) { log.error('getStanding', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // DELETE /admin/users/:id/shard/link/:account — unlink a game account from this // user, site-side. `actor` is stamped from the session (never the browser). On // success the sidecar clears the WebsiteUserId tag on the shard and we drop the // local mirror so attribution stops immediately. async function unlinkAccount(req, res) { const { account } = req.params try { const ctx = await accountsForUser(Number(req.params.id)) if (!ctx) return res.status(404).json({ message: 'Not found' }) // Only unlink an account actually linked to THIS user (avoid cross-user unlink). if (!ctx.accounts.includes(account)) { return res.status(404).json({ message: 'That account is not linked to this user.' }) } const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account }) if (result.ok) { await shardLinks.removeByAccount(account) await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } }) log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username }) return res.json({ account, unlinked: true }) } if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' }) if (result.status === 404) { // Not linked on the shard — reconcile our mirror anyway so the two agree. await shardLinks.removeByAccount(account) return res.status(404).json({ message: 'That account is not linked.' }) } if (result.status === 503 || result.status === 0) { return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' }) } return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' }) } catch (err) { log.error('unlinkAccount', err) return res.status(500).json({ message: 'Internal Server Error' }) } } module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }