feat(admin): view a user's shard footprint at /admin/users/:id

Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.

Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
  so no new routes for roster/vendors/char

Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title

Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-12 09:36:43 -05:00
parent 696d82f114
commit ba4d758eab
13 changed files with 586 additions and 32 deletions

View File

@@ -0,0 +1,86 @@
// ── 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 { 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' })
}
}
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline }