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')
+})