Merge pull request 'feat(admin): view a user's shard footprint at /admin/users/:id' (#56) from feature/admin-user-view into main
All checks were successful
Build container images / build (push) Successful in 1m48s
All checks were successful
Build container images / build (push) Successful in 1m48s
Reviewed-on: UOM/website#56 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -40,6 +40,7 @@ import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
|||||||
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.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 AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
import Moderation from './routes/admin/views/Moderation.jsx'
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
@@ -124,6 +125,7 @@ export default function App() {
|
|||||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
|
<Route path="users/:id" element={<UserDetail />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -159,9 +159,23 @@ export const api = {
|
|||||||
botActivity: () => req('/admin/bot-activity'),
|
botActivity: () => req('/admin/bot-activity'),
|
||||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||||
listUsers: () => req('/admin/users'),
|
listUsers: () => req('/admin/users'),
|
||||||
|
getUser: (id) => req(`/admin/users/${id}`),
|
||||||
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
|
||||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
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) -----
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { Loading, ErrorState } from './PageState.jsx'
|
|||||||
// Shared game-account linking + character roster, used by both the player portal
|
// Shared game-account linking + character roster, used by both the player portal
|
||||||
// (/player) and the staff account page (/admin/account). `scope` is the api
|
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||||
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
// 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 }) {
|
function LinkForm({ scope, onLinked, compact }) {
|
||||||
const [code, setCode] = useState('')
|
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 [accounts, setAccounts] = useState(null)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -114,16 +116,26 @@ export default function GameAccounts({ scope, charTo }) {
|
|||||||
try {
|
try {
|
||||||
setAccounts(await scope.accounts())
|
setAccounts(await scope.accounts())
|
||||||
} catch {
|
} 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])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
if (error) return <ErrorState message={error} />
|
if (error) return <ErrorState message={error} />
|
||||||
if (!accounts) return <Loading />
|
if (!accounts) return <Loading />
|
||||||
|
|
||||||
// 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 (accounts.length === 0) {
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||||
|
This user has not linked a game account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="panel" style={{ padding: 22 }}>
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||||
@@ -147,10 +159,12 @@ export default function GameAccounts({ scope, charTo }) {
|
|||||||
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
{!readOnly && (
|
||||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||||
<LinkForm scope={scope} onLinked={load} compact />
|
<LinkForm scope={scope} onLinked={load} compact />
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export default function AdminLayout() {
|
|||||||
? 'Moderation'
|
? 'Moderation'
|
||||||
: location.pathname.startsWith('/admin/characters')
|
: location.pathname.startsWith('/admin/characters')
|
||||||
? 'My Characters'
|
? 'My Characters'
|
||||||
|
: location.pathname.startsWith('/admin/users/')
|
||||||
|
? 'User'
|
||||||
: 'Admin')
|
: 'Admin')
|
||||||
// The hero canvas editor needs room — let it use the full content width.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
|
|||||||
152
client/src/routes/admin/views/UserDetail.jsx
Normal file
152
client/src/routes/admin/views/UserDetail.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Online now</SectionTitle>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.map((c) => (
|
||||||
|
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||||||
|
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||||
|
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||||
|
function Houses({ scope }) {
|
||||||
|
const { data } = useAsync(() => scope.houses(), [scope])
|
||||||
|
if (!data) return null
|
||||||
|
return (
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||||
|
<SectionTitle>Houses</SectionTitle>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{data.map((h) => (
|
||||||
|
<li
|
||||||
|
key={h.serial}
|
||||||
|
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||||
|
{h.name || 'Unnamed house'}
|
||||||
|
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||||
|
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
||||||
|
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||||
|
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||||
|
{h.stage ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.stage}</div> : null}
|
||||||
|
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShardSections({ scope }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CharacterStats scope={scope} />
|
||||||
|
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||||
|
<GameAccounts scope={scope} readOnly charTo={(serial) => `/admin/characters/${serial}`} />
|
||||||
|
<OnlineNow scope={scope} />
|
||||||
|
<Houses scope={scope} />
|
||||||
|
<VendorSales fetchSales={scope.sales} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load this user." />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Link to="/admin/users" className="link-accent" style={{ fontSize: '0.85rem' }}>
|
||||||
|
← Back to users
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
|
||||||
|
{user.username}
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${ROLE_BADGE[user.role] || 'badge-editor'}`}>{user.role}</span>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ fontSize: '0.82rem', color: user.status && user.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
|
||||||
|
>
|
||||||
|
{user.status || 'active'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ display: 'flex', gap: 18, marginTop: 10, flexWrap: 'wrap', fontSize: '0.8rem' }}>
|
||||||
|
{user.email && <span>{user.email}</span>}
|
||||||
|
<span>Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}</span>
|
||||||
|
{user.created_at && <span>Joined: {dateTime(user.created_at)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShardSections scope={scope} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
import { useAsync } from '../../../lib/useAsync.js'
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
import { dateTime } from '../../../lib/format.js'
|
import { dateTime } from '../../../lib/format.js'
|
||||||
@@ -13,6 +14,7 @@ const ROLE_BADGE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersAdmin() {
|
export default function UsersAdmin() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
|
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
|
||||||
@@ -64,9 +66,14 @@ export default function UsersAdmin() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
<span style={{ display: 'inline-flex', gap: 16, justifyContent: 'flex-end' }}>
|
||||||
|
<span className="link-accent" onClick={() => navigate(`/admin/users/${u.id}`)}>
|
||||||
|
View
|
||||||
|
</span>
|
||||||
<span className="link-accent" onClick={() => setEditing(u)}>
|
<span className="link-accent" onClick={() => setEditing(u)}>
|
||||||
Edit
|
Edit
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ async function countOnline() {
|
|||||||
const listOnline = () =>
|
const listOnline = () =>
|
||||||
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
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
|
// 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
|
// who link an account are NOT surfaced publicly — only staff opt into visibility
|
||||||
// by virtue of being staff.
|
// by virtue of being staff.
|
||||||
@@ -89,6 +101,18 @@ async function upsertHouse(serial, fields) {
|
|||||||
const listIdocHouses = () =>
|
const listIdocHouses = () =>
|
||||||
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
|
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 = {
|
module.exports = {
|
||||||
upsertOnline,
|
upsertOnline,
|
||||||
removeOnline,
|
removeOnline,
|
||||||
@@ -96,9 +120,11 @@ module.exports = {
|
|||||||
countOnline,
|
countOnline,
|
||||||
listOnline,
|
listOnline,
|
||||||
listOnlineLinked,
|
listOnlineLinked,
|
||||||
|
listOnlineByAccounts,
|
||||||
insertEconomy,
|
insertEconomy,
|
||||||
listEconomy,
|
listEconomy,
|
||||||
latestEconomy,
|
latestEconomy,
|
||||||
upsertHouse,
|
upsertHouse,
|
||||||
listIdocHouses,
|
listIdocHouses,
|
||||||
|
listHousesByAccounts,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,9 +136,8 @@ async function upsertHouse(data) {
|
|||||||
await db.upsertHouse(data.serial, fields)
|
await db.upsertHouse(data.serial, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listIdoc() {
|
function shapeHouse(r) {
|
||||||
const rows = await db.listIdocHouses()
|
return {
|
||||||
return rows.map((r) => ({
|
|
||||||
serial: r.serial,
|
serial: r.serial,
|
||||||
stage: r.stage,
|
stage: r.stage,
|
||||||
map: r.map,
|
map: r.map,
|
||||||
@@ -153,7 +152,24 @@ async function listIdoc() {
|
|||||||
lastRefreshed: r.last_refreshed,
|
lastRefreshed: r.last_refreshed,
|
||||||
isIdoc: Boolean(r.is_idoc),
|
isIdoc: Boolean(r.is_idoc),
|
||||||
updatedAt: r.updated_at,
|
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 = {
|
module.exports = {
|
||||||
@@ -163,9 +179,11 @@ module.exports = {
|
|||||||
onlineCount,
|
onlineCount,
|
||||||
listOnline,
|
listOnline,
|
||||||
listOnlineLinked,
|
listOnlineLinked,
|
||||||
|
listOnlineForAccounts,
|
||||||
addEconomySample,
|
addEconomySample,
|
||||||
listEconomy,
|
listEconomy,
|
||||||
latestEconomy,
|
latestEconomy,
|
||||||
upsertHouse,
|
upsertHouse,
|
||||||
listIdoc,
|
listIdoc,
|
||||||
|
listHousesForAccounts,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
|
|||||||
const discordBot = require('./discordBot.controller')
|
const discordBot = require('./discordBot.controller')
|
||||||
const emailConfig = require('./emailConfig.controller')
|
const emailConfig = require('./emailConfig.controller')
|
||||||
const uoLink = require('./uoLink.controller')
|
const uoLink = require('./uoLink.controller')
|
||||||
|
const usersShard = require('./usersShard.controller')
|
||||||
const selfShard = require('../player/shard.controller')
|
const selfShard = require('../player/shard.controller')
|
||||||
const moderation = require('./moderation.controller')
|
const moderation = require('./moderation.controller')
|
||||||
const pagesCtrl = require('./pages.controller')
|
const pagesCtrl = require('./pages.controller')
|
||||||
@@ -1082,6 +1083,72 @@ adminRouter.delete(
|
|||||||
ctrl.deleteUser,
|
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) ──────────────────────────────────
|
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||||||
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
||||||
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
|
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
|
||||||
|
|||||||
86
server/src/router/v1/admin/usersShard.controller.js
Normal file
86
server/src/router/v1/admin/usersShard.controller.js
Normal 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 }
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
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 activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('player-shard')
|
const log = require('../../../utils/logger')('player-shard')
|
||||||
@@ -120,21 +120,8 @@ async function getChar(req, res) {
|
|||||||
async function getSales(req, res) {
|
async function getSales(req, res) {
|
||||||
try {
|
try {
|
||||||
const links = await shardLinks.listForUser(req.user.id)
|
const links = await shardLinks.listForUser(req.user.id)
|
||||||
const accounts = new Set(links.map((l) => l.account))
|
const accounts = links.map((l) => l.account)
|
||||||
if (accounts.size === 0) return res.json([])
|
return res.json(await salesForAccounts(accounts))
|
||||||
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)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('player.shard.getSales', err)
|
log.error('player.shard.getSales', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
|||||||
26
server/src/utils/shardSales.js
Normal file
26
server/src/utils/shardSales.js
Normal file
@@ -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 }
|
||||||
153
server/test/adminUserShard.test.js
Normal file
153
server/test/adminUserShard.test.js
Normal 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 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')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user