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,153 @@
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
// so any stray query fails fast instead of hanging the runner. These tests stub
// every model method the controller touches, so the DB is never actually hit.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const ctrl = require('../src/router/v1/admin/usersShard.controller')
const users = require('../src/model/users/users.model')
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
const shardState = require('../src/model/shardState/shardState.model')
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
const { salesForAccounts } = require('../src/utils/shardSales')
const db = require('../src/utils/db')
after(() => db.close())
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
// Save/restore the originals so each test's monkeypatches don't leak.
const originals = {
getById: users.getById,
listForUser: shardLinks.listForUser,
listHousesForAccounts: shardState.listHousesForAccounts,
listOnlineForAccounts: shardState.listOnlineForAccounts,
eventsList: shardEvents.list,
}
afterEach(() => {
users.getById = originals.getById
shardLinks.listForUser = originals.listForUser
shardState.listHousesForAccounts = originals.listHousesForAccounts
shardState.listOnlineForAccounts = originals.listOnlineForAccounts
shardEvents.list = originals.eventsList
})
// ── salesForAccounts util ──────────────────────────────────────────────────
test('salesForAccounts returns [] for an empty account set without hitting the log', async () => {
let called = false
shardEvents.list = async () => {
called = true
return []
}
assert.deepEqual(await salesForAccounts([]), [])
assert.equal(called, false)
})
test('salesForAccounts keeps only sales owned by the given accounts, newest 50', async () => {
const events = []
// 60 sales owned by "mine", plus some owned by "other".
for (let i = 0; i < 60; i++) {
events.push({ t: i, payload: { ownerAcct: 'mine', itemType: 'sword', amount: 1, price: 10, commission: 1 } })
}
events.push({ t: 999, payload: { ownerAcct: 'other', itemType: 'shield', amount: 1, price: 5 } })
shardEvents.list = async () => events
const rows = await salesForAccounts(['mine'])
assert.equal(rows.length, 50) // capped
assert.ok(rows.every((r) => r.ownerAcct === 'mine')) // never leaks "other"
assert.deepEqual(Object.keys(rows[0]).sort(), ['amount', 'commission', 'itemType', 'ownerAcct', 'price', 't'])
})
// ── Controller: unknown user → 404 ─────────────────────────────────────────
for (const handler of ['getUser', 'listAccounts', 'getSales', 'getHouses', 'getOnline']) {
test(`${handler} returns 404 when the user does not exist`, async () => {
users.getById = async () => null
const res = mockRes()
await ctrl[handler]({ params: { id: '404' } }, res)
assert.equal(res.statusCode, 404)
})
}
// ── Controller: scoping to the user's accounts ─────────────────────────────
test('listAccounts returns the users linked accounts', async () => {
users.getById = async () => ({ id: 7, username: 'bob', role: 'player' })
shardLinks.listForUser = async (id) => {
assert.equal(id, 7)
return [{ account: 'acctA' }, { account: 'acctB' }]
}
const res = mockRes()
await ctrl.listAccounts({ params: { id: '7' } }, res)
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, [{ account: 'acctA' }, { account: 'acctB' }])
})
test('getHouses passes exactly the users accounts to the model', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => [{ account: 'acctA' }, { account: 'acctB' }]
let received = null
shardState.listHousesForAccounts = async (accounts) => {
received = accounts
return [{ serial: '0x1', isIdoc: true }]
}
const res = mockRes()
await ctrl.getHouses({ params: { id: '7' } }, res)
assert.deepEqual(received, ['acctA', 'acctB'])
assert.deepEqual(res.body, [{ serial: '0x1', isIdoc: true }])
})
test('getOnline passes exactly the users accounts to the model', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => [{ account: 'acctA' }]
let received = null
shardState.listOnlineForAccounts = async (accounts) => {
received = accounts
return [{ serial: '0x2', name: 'Zoe' }]
}
const res = mockRes()
await ctrl.getOnline({ params: { id: '7' } }, res)
assert.deepEqual(received, ['acctA'])
assert.deepEqual(res.body, [{ serial: '0x2', name: 'Zoe' }])
})
test('a user with no linked accounts yields empty sales/houses/online', async () => {
users.getById = async () => ({ id: 7 })
shardLinks.listForUser = async () => []
shardState.listHousesForAccounts = async (a) => (a.length ? [{}] : [])
shardState.listOnlineForAccounts = async (a) => (a.length ? [{}] : [])
shardEvents.list = async () => [{ payload: { ownerAcct: 'someoneElse' } }]
const sales = mockRes()
const houses = mockRes()
const online = mockRes()
await ctrl.getSales({ params: { id: '7' } }, sales)
await ctrl.getHouses({ params: { id: '7' } }, houses)
await ctrl.getOnline({ params: { id: '7' } }, online)
assert.deepEqual(sales.body, [])
assert.deepEqual(houses.body, [])
assert.deepEqual(online.body, [])
})
test('getUser returns the sanitized user row', async () => {
users.getById = async () => ({ id: 7, username: 'bob', role: 'player', status: 'active' })
const res = mockRes()
await ctrl.getUser({ params: { id: '7' } }, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.username, 'bob')
})