From ba4d758eab1cbfd0c188c3705484f72d929800f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 09:36:43 -0500 Subject: [PATCH] feat(admin): view a user's shard footprint at /admin/users/:id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- client/src/App.jsx | 2 + client/src/api/client.js | 14 ++ client/src/components/GameAccounts.jsx | 32 ++-- client/src/routes/admin/AdminLayout.jsx | 4 +- client/src/routes/admin/views/UserDetail.jsx | 152 +++++++++++++++++ client/src/routes/admin/views/UsersAdmin.jsx | 11 +- server/src/model/shardState/shardState.db.js | 26 +++ .../src/model/shardState/shardState.model.js | 26 ++- server/src/router/v1/admin/admin.routes.js | 67 ++++++++ .../router/v1/admin/usersShard.controller.js | 86 ++++++++++ .../src/router/v1/player/shard.controller.js | 19 +-- server/src/utils/shardSales.js | 26 +++ server/test/adminUserShard.test.js | 153 ++++++++++++++++++ 13 files changed, 586 insertions(+), 32 deletions(-) create mode 100644 client/src/routes/admin/views/UserDetail.jsx create mode 100644 server/src/router/v1/admin/usersShard.controller.js create mode 100644 server/src/utils/shardSales.js create mode 100644 server/test/adminUserShard.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 56caeee..8c428e8 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -40,6 +40,7 @@ import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' +import UserDetail from './routes/admin/views/UserDetail.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' @@ -124,6 +125,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 8fde9b1..0ca383e 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -159,9 +159,23 @@ export const api = { botActivity: () => req('/admin/bot-activity'), unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }), listUsers: () => req('/admin/users'), + getUser: (id) => req(`/admin/users/${id}`), createUser: (data) => req('/admin/users', { method: 'POST', body: data }), updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }), + // A single user's shard (uo-link) footprint, scoped to their linked accounts. + // accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char + // reuse the admin-bypass /admin/shard/* endpoints (which already read any + // account) so the shared GameAccounts component works unchanged. + userShard: (id) => ({ + accounts: () => req(`/admin/users/${id}/shard/accounts`), + roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`), + vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`), + char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), + sales: () => req(`/admin/users/${id}/shard/sales`), + houses: () => req(`/admin/users/${id}/shard/houses`), + online: () => req(`/admin/users/${id}/shard/online`), + }), // ----- moderation dashboard (admin + moderator) ----- modSummary: () => req('/admin/moderation/stats/summary'), diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx index c4617e7..c4180f8 100644 --- a/client/src/components/GameAccounts.jsx +++ b/client/src/components/GameAccounts.jsx @@ -5,7 +5,9 @@ import { Loading, ErrorState } from './PageState.jsx' // Shared game-account linking + character roster, used by both the player portal // (/player) and the staff account page (/admin/account). `scope` is the api // object with { link, accounts, roster } (player or admin self-service); `charTo` -// maps a serial to the route for that character's sheet. +// maps a serial to the route for that character's sheet. `readOnly` drops the +// link forms and self-voice copy for the admin case where staff view *another* +// user's accounts (no `scope.link`) at /admin/users/:id. function LinkForm({ scope, onLinked, compact }) { const [code, setCode] = useState('') @@ -105,7 +107,7 @@ function AccountRoster({ scope, account, charTo }) { ) } -export default function GameAccounts({ scope, charTo }) { +export default function GameAccounts({ scope, charTo, readOnly = false }) { const [accounts, setAccounts] = useState(null) const [error, setError] = useState('') @@ -114,16 +116,26 @@ export default function GameAccounts({ scope, charTo }) { try { setAccounts(await scope.accounts()) } catch { - setError('Could not load your game accounts.') + setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.') } - }, [scope]) + }, [scope, readOnly]) useEffect(() => { load() }, [load]) if (error) return if (!accounts) return - // Not linked yet — prompt to link. + // No linked accounts. In read-only (admin viewing another user) this is just an + // empty state; otherwise it's the link-your-account prompt. if (accounts.length === 0) { + if (readOnly) { + return ( +
+

+ This user has not linked a game account. +

+
+ ) + } return (
Link your game account
@@ -147,10 +159,12 @@ export default function GameAccounts({ scope, charTo }) { ))} -
-
Link another account
- -
+ {!readOnly && ( +
+
Link another account
+ +
+ )}
) } diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 30bdd32..9cce70b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -129,7 +129,9 @@ export default function AdminLayout() { ? 'Moderation' : location.pathname.startsWith('/admin/characters') ? 'My Characters' - : 'Admin') + : location.pathname.startsWith('/admin/users/') + ? 'User' + : 'Admin') // The hero canvas editor needs room — let it use the full content width. const wide = location.pathname === '/admin/hero' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx new file mode 100644 index 0000000..a2033e2 --- /dev/null +++ b/client/src/routes/admin/views/UserDetail.jsx @@ -0,0 +1,152 @@ +import { useMemo } from 'react' +import { useParams, Link } from 'react-router-dom' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAsync } from '../../../lib/useAsync.js' +import { dateTime, ago } from '../../../lib/format.js' +import { api } from '../../../api/client.js' +import CharacterStats from '../../../components/CharacterStats.jsx' +import GameAccounts from '../../../components/GameAccounts.jsx' +import VendorSales from '../../../components/VendorSales.jsx' + +// Admin read-only view of one user's shard (uo-link) footprint: linked game +// accounts + character rosters, currently-online characters, houses (incl. +// IDOC) and recent vendor sales — everything scoped to that user's accounts. +// Reached from the Users table's "View" action; Edit stays a separate modal. + +const ROLE_BADGE = { + admin: 'badge-admin', + editor: 'badge-editor', + moderator: 'badge-moderator', + player: 'badge-player', +} + +function SectionTitle({ children }) { + return ( +
+ {children} +
+ ) +} + +// Currently-online characters on the user's accounts, with where they are. The +// per-character Online/Offline badge lives in the roster; this adds location. +function OnlineNow({ scope }) { + const { data } = useAsync(() => scope.online(), [scope]) + if (!data) return null + return ( +
+ Online now + {data.length === 0 ? ( +

No characters online right now.

+ ) : ( +
    + {data.map((c) => ( +
  • + + + {c.name || '(unnamed)'} + + + {c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'} + +
  • + ))} +
+ )} +
+ ) +} + +// Houses owned by the user's accounts, IDOC first (flagged). +function Houses({ scope }) { + const { data } = useAsync(() => scope.houses(), [scope]) + if (!data) return null + return ( +
+ Houses + {data.length === 0 ? ( +

No houses recorded for this user’s accounts.

+ ) : ( +
    + {data.map((h) => ( +
  • +
    +
    + {h.name || 'Unnamed house'} + {h.isIdoc && IDOC} +
    +
    + {h.region || (h.map != null ? `map ${h.map}` : 'unknown')} + {h.x != null ? ` · ${h.x}, ${h.y}` : ''} + {h.ownerAcct ? ` · ${h.ownerAcct}` : ''} +
    +
    +
    + {h.stage ?
    {h.stage}
    : null} + {h.lastRefreshed ?
    refreshed {ago(h.lastRefreshed)}
    : null} +
    +
  • + ))} +
+ )} +
+ ) +} + +function ShardSections({ scope }) { + return ( + <> + + Linked accounts & characters + `/admin/characters/${serial}`} /> + + + + + ) +} + +export default function UserDetail() { + const { id } = useParams() + // Memoize so the child components' effects (keyed on `scope`) don't refetch + // on every render. + const scope = useMemo(() => api.admin.userShard(id), [id]) + const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id]) + + if (loading) return + if (error) return + + return ( +
+ + ← Back to users + + + {/* Header */} +
+
+ + {user.username} + + {user.role} + + {user.status || 'active'} + +
+
+ {user.email && {user.email}} + Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'} + {user.created_at && Joined: {dateTime(user.created_at)}} +
+
+ + +
+ ) +} diff --git a/client/src/routes/admin/views/UsersAdmin.jsx b/client/src/routes/admin/views/UsersAdmin.jsx index 7070620..549ce9c 100644 --- a/client/src/routes/admin/views/UsersAdmin.jsx +++ b/client/src/routes/admin/views/UsersAdmin.jsx @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { useAsync } from '../../../lib/useAsync.js' import { dateTime } from '../../../lib/format.js' @@ -13,6 +14,7 @@ const ROLE_BADGE = { } export default function UsersAdmin() { + const navigate = useNavigate() const [tick, setTick] = useState(0) const reload = useCallback(() => setTick((t) => t + 1), []) const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick]) @@ -64,8 +66,13 @@ export default function UsersAdmin() { {u.last_login_at ? dateTime(u.last_login_at) : 'never'} - setEditing(u)}> - Edit + + navigate(`/admin/users/${u.id}`)}> + View + + setEditing(u)}> + Edit + diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js index 74384f7..2aa9138 100644 --- a/server/src/model/shardState/shardState.db.js +++ b/server/src/model/shardState/shardState.db.js @@ -33,6 +33,18 @@ async function countOnline() { const listOnline = () => query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`) +// Online players on any of the given game accounts (admin: a user's linked +// accounts). Empty list short-circuits so we never emit `IN ()`. +const listOnlineByAccounts = (accounts) => + accounts.length === 0 + ? Promise.resolve([]) + : query( + `SELECT ${ONLINE_COLS} FROM shard_online + WHERE acct IN (${accounts.map(() => '?').join(', ')}) + ORDER BY name ASC`, + accounts, + ) + // Staff roles whose online presence is shown on the public Shard page. Players // who link an account are NOT surfaced publicly — only staff opt into visibility // by virtue of being staff. @@ -89,6 +101,18 @@ async function upsertHouse(serial, fields) { const listIdocHouses = () => query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`) +// Houses owned by any of the given game accounts (admin: a user's linked +// accounts). IDOC houses first, then newest-refreshed. Empty list short-circuits. +const listHousesByAccounts = (accounts) => + accounts.length === 0 + ? Promise.resolve([]) + : query( + `SELECT ${HOUSE_COLS} FROM shard_houses + WHERE owner_acct IN (${accounts.map(() => '?').join(', ')}) + ORDER BY is_idoc DESC, updated_at DESC`, + accounts, + ) + module.exports = { upsertOnline, removeOnline, @@ -96,9 +120,11 @@ module.exports = { countOnline, listOnline, listOnlineLinked, + listOnlineByAccounts, insertEconomy, listEconomy, latestEconomy, upsertHouse, listIdocHouses, + listHousesByAccounts, } diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js index 87e31c4..4fd9e56 100644 --- a/server/src/model/shardState/shardState.model.js +++ b/server/src/model/shardState/shardState.model.js @@ -136,9 +136,8 @@ async function upsertHouse(data) { await db.upsertHouse(data.serial, fields) } -async function listIdoc() { - const rows = await db.listIdocHouses() - return rows.map((r) => ({ +function shapeHouse(r) { + return { serial: r.serial, stage: r.stage, map: r.map, @@ -153,7 +152,24 @@ async function listIdoc() { lastRefreshed: r.last_refreshed, isIdoc: Boolean(r.is_idoc), updatedAt: r.updated_at, - })) + } +} + +async function listIdoc() { + const rows = await db.listIdocHouses() + return rows.map(shapeHouse) +} + +// Houses owned by the given game accounts (admin: a user's linked accounts). +async function listHousesForAccounts(accounts) { + const rows = await db.listHousesByAccounts(accounts) + return rows.map(shapeHouse) +} + +// Online players on the given game accounts (admin: a user's linked accounts). +async function listOnlineForAccounts(accounts) { + const rows = await db.listOnlineByAccounts(accounts) + return rows.map(shapeOnline) } module.exports = { @@ -163,9 +179,11 @@ module.exports = { onlineCount, listOnline, listOnlineLinked, + listOnlineForAccounts, addEconomySample, listEconomy, latestEconomy, upsertHouse, listIdoc, + listHousesForAccounts, } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 6e6bf00..9965c50 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller') const discordBot = require('./discordBot.controller') const emailConfig = require('./emailConfig.controller') const uoLink = require('./uoLink.controller') +const usersShard = require('./usersShard.controller') const selfShard = require('../player/shard.controller') const moderation = require('./moderation.controller') const pagesCtrl = require('./pages.controller') @@ -1082,6 +1083,72 @@ adminRouter.delete( ctrl.deleteUser, ) +// ── User → shard (uo-link) footprint (admin only) ───────────────────── +// Backs the /admin/users/:id detail page: a user's linked game accounts and, +// scoped to those accounts, their vendor sales / houses / online characters. +// Live character rosters are fetched by the client through /admin/shard/* (which +// already grants admins a bypass to any account), so no routes for them here. +adminRouter.get( + '/users/:id', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Get a single user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getUser, +) +adminRouter.get( + '/users/:id/shard/accounts', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s linked game accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.listAccounts, +) +adminRouter.get( + '/users/:id/shard/sales', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getSales, +) +adminRouter.get( + '/users/:id/shard/houses', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Houses owned by a user’s accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getHouses, +) +adminRouter.get( + '/users/:id/shard/online', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s characters currently online (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getOnline, +) + // ── uo-link sidecar control (admin only) ────────────────────────────────── // Connection config (base/ws URL + token + protocol + enabled) and the town // crier. The token is write-only (SECURITY note in uoLink.controller.js). diff --git a/server/src/router/v1/admin/usersShard.controller.js b/server/src/router/v1/admin/usersShard.controller.js new file mode 100644 index 0000000..d9e42a3 --- /dev/null +++ b/server/src/router/v1/admin/usersShard.controller.js @@ -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 } diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js index 0f9359c..cef8929 100644 --- a/server/src/router/v1/player/shard.controller.js +++ b/server/src/router/v1/player/shard.controller.js @@ -9,7 +9,7 @@ const uoLinkClient = require('../../../utils/uoLinkClient') const shardLinks = require('../../../model/shardLinks/shardLinks.model') -const shardEvents = require('../../../model/shardEvents/shardEvents.model') +const { salesForAccounts } = require('../../../utils/shardSales') const activity = require('../../../model/activity/activity.model') const log = require('../../../utils/logger')('player-shard') @@ -120,21 +120,8 @@ async function getChar(req, res) { async function getSales(req, res) { try { const links = await shardLinks.listForUser(req.user.id) - const accounts = new Set(links.map((l) => l.account)) - if (accounts.size === 0) return res.json([]) - const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 }) - const mine = events - .filter((e) => e.payload && accounts.has(e.payload.ownerAcct)) - .slice(0, 50) - .map((e) => ({ - t: e.t, - itemType: e.payload.itemType, - amount: e.payload.amount, - price: e.payload.price, - commission: e.payload.commission, - ownerAcct: e.payload.ownerAcct, - })) - return res.json(mine) + const accounts = links.map((l) => l.account) + return res.json(await salesForAccounts(accounts)) } catch (err) { log.error('player.shard.getSales', err) return res.status(500).json({ message: 'Internal Server Error' }) diff --git a/server/src/utils/shardSales.js b/server/src/utils/shardSales.js new file mode 100644 index 0000000..3625f91 --- /dev/null +++ b/server/src/utils/shardSales.js @@ -0,0 +1,26 @@ +// Recent player-vendor sales for a set of game accounts. Shared by the player +// self endpoint (the caller's linked accounts) and the admin user-detail +// endpoint (a target user's linked accounts). Reads the site's own ingested +// event log — no sidecar round-trip — and filters to sales whose owning account +// is in the set. Newest 50, already newest-first from shardEvents.list. + +const shardEvents = require('../model/shardEvents/shardEvents.model') + +async function salesForAccounts(accounts) { + const set = accounts instanceof Set ? accounts : new Set(accounts) + if (set.size === 0) return [] + const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 }) + return events + .filter((e) => e.payload && set.has(e.payload.ownerAcct)) + .slice(0, 50) + .map((e) => ({ + t: e.t, + itemType: e.payload.itemType, + amount: e.payload.amount, + price: e.payload.price, + commission: e.payload.commission, + ownerAcct: e.payload.ownerAcct, + })) +} + +module.exports = { salesForAccounts } diff --git a/server/test/adminUserShard.test.js b/server/test/adminUserShard.test.js new file mode 100644 index 0000000..f54edb2 --- /dev/null +++ b/server/test/adminUserShard.test.js @@ -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 user’s 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 user’s 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 user’s 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') +})