chore(quality): resolve SonarQube code smells across website
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s

Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-21 04:35:39 -05:00
parent 4993470fa2
commit 12d50fd615
59 changed files with 1088 additions and 848 deletions

View File

@@ -10,8 +10,8 @@ async function log({ req, userId, action, detail }) {
try {
const resolvedUserId = userId ?? (req && req.user ? req.user.id : null)
const ip = req ? req.ip : null
const detailStr =
detail == null ? null : typeof detail === 'string' ? detail : JSON.stringify(detail)
let detailStr = null
if (detail != null) detailStr = typeof detail === 'string' ? detail : JSON.stringify(detail)
await activityDb.insert({ userId: resolvedUserId, action, detail: detailStr, ip })
} catch (err) {
logger.error(`failed to record action "${action}"`, { error: err.message })

View File

@@ -40,7 +40,7 @@ function reshapeWindows(rows) {
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
function windowValue(row, key) {
if (!row) return 0
const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30
const col = { '24h': row.d1, '7d': row.d7 }[key] ?? row.d30
return Number(col) || 0
}

View File

@@ -130,35 +130,49 @@ function mapMetadata(metadata) {
return cols
}
// Per-setting validators — each throws a 400 or returns the accepted value. Split
// out of mapSettings so that function stays a flat dispatch (keeps its cognitive
// complexity low).
function validLayout(v) {
if (!LAYOUTS.includes(v)) {
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
}
return v
}
function validShowInNav(v) {
if (typeof v !== 'boolean') {
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
}
return v ? 1 : 0
}
function validNavGroup(v) {
if (v !== null && !NAV_GROUPS.includes(v)) {
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
}
return v
}
function validNavOrder(v) {
if (v !== null && !Number.isInteger(v)) {
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
}
return v
}
function validTitle(v) {
if (typeof v !== 'string' || v.trim() === '' || v.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
return v.trim()
}
// Map the grouped `settings` object to DB columns (except `protected`, which is
// handled by the caller so the unprotect rule stays centralized).
function mapSettings(settings) {
const cols = {}
if (!settings || typeof settings !== 'object') return cols
if ('layout' in settings) {
if (!LAYOUTS.includes(settings.layout)) {
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
}
cols.layout = settings.layout
}
if ('showInNav' in settings) {
if (typeof settings.showInNav !== 'boolean') {
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
}
cols.show_in_nav = settings.showInNav ? 1 : 0
}
if ('navGroup' in settings) {
if (settings.navGroup !== null && !NAV_GROUPS.includes(settings.navGroup)) {
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
}
cols.nav_group = settings.navGroup
}
if ('navOrder' in settings) {
if (settings.navOrder !== null && !Number.isInteger(settings.navOrder)) {
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
}
cols.nav_order = settings.navOrder
}
if ('layout' in settings) cols.layout = validLayout(settings.layout)
if ('showInNav' in settings) cols.show_in_nav = validShowInNav(settings.showInNav)
if ('navGroup' in settings) cols.nav_group = validNavGroup(settings.navGroup)
if ('navOrder' in settings) cols.nav_order = validNavOrder(settings.navOrder)
return cols
}
@@ -191,13 +205,10 @@ async function create(input, authorId) {
const { slug, title, blocks = [], status = 'draft', metadata, settings } = input
assertSlug(slug)
assertStatus(status)
if (typeof title !== 'string' || title.trim() === '' || title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
const row = {
slug,
title: title.trim(),
title: validTitle(title),
blocks: buildBlocks(blocks),
status,
author_id: authorId,
@@ -219,6 +230,26 @@ async function create(input, authorId) {
return getById(id)
}
// Apply the status patch, stamping published_at the first time a page publishes.
function applyStatusPatch(fields, status, current) {
assertStatus(status)
fields.status = status
if (status === 'published' && !current.published_at) fields.published_at = new Date()
}
// Protected transitions: turning protection ON is allowed here; turning it OFF is
// not (must go through the password-gated unprotect endpoint), regardless of the
// request body. Turning OFF while already unprotected is a no-op.
function applyProtectedPatch(fields, settings, current) {
if (!settings || !('protected' in settings)) return
const want = settings.protected
if (want === true) {
fields.protected = 1
} else if (want === false && current.protected) {
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
}
}
async function update(id, patch) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
@@ -230,41 +261,13 @@ async function update(id, patch) {
}
const fields = {}
if (patch.title !== undefined) {
if (typeof patch.title !== 'string' || patch.title.trim() === '' || patch.title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
fields.title = patch.title.trim()
}
if (patch.blocks !== undefined) {
fields.blocks = buildBlocks(patch.blocks)
}
if (patch.status !== undefined) {
assertStatus(patch.status)
fields.status = patch.status
// Stamp published_at the first time a page becomes published.
if (patch.status === 'published' && !current.published_at) {
fields.published_at = new Date()
}
}
if (patch.title !== undefined) fields.title = validTitle(patch.title)
if (patch.blocks !== undefined) fields.blocks = buildBlocks(patch.blocks)
if (patch.status !== undefined) applyStatusPatch(fields, patch.status, current)
Object.assign(fields, mapMetadata(patch.metadata))
Object.assign(fields, mapSettings(patch.settings))
// Protected transitions: ON is allowed here; OFF is not (must go through the
// password-gated unprotect endpoint), regardless of the request body.
if (patch.settings && 'protected' in patch.settings) {
const want = patch.settings.protected
if (want === true) {
fields.protected = 1
} else if (want === false && current.protected) {
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
}
// want === false while already unprotected → no-op.
}
applyProtectedPatch(fields, patch.settings, current)
await pagesDb.update(id, fields)
return getById(id)

View File

@@ -15,7 +15,8 @@ function stableStringify(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
const keys = Object.keys(value).sort()
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
return `{${entries.join(',')}}`
}
// dedupe_key = sha256(kind + t + stable-json(payload)), truncated to 40 hex chars.

View File

@@ -54,9 +54,12 @@ const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
// effect immediately, regardless of whether the player has re-logged since
// linking, then through to users so only staff roles are surfaced publicly.
const listOnlineLinked = () =>
query(
`SELECT ${ONLINE_COLS.split(', ').map((c) => `o.${c}`).join(', ')}
const listOnlineLinked = () => {
const cols = ONLINE_COLS.split(', ')
.map((c) => `o.${c}`)
.join(', ')
return query(
`SELECT ${cols}
FROM shard_online o
JOIN shard_account_links l ON l.account = o.acct
JOIN users u ON u.id = l.user_id
@@ -64,6 +67,7 @@ const listOnlineLinked = () =>
ORDER BY o.name ASC`,
PUBLIC_ONLINE_ROLES,
)
}
// ── Economy supply series ────────────────────────────────────────────────
const insertEconomy = ({ accounts, gold, t }) =>

View File

@@ -8,6 +8,17 @@ const db = require('./shardState.db')
const MAX_ECONOMY = 1000
// Small coercion helpers, kept out of the upsert builders below so those stay
// flat (each inline `?? null` / ternary otherwise adds to cognitive complexity).
const orNull = (v) => v ?? null
const toDate = (v) => (v ? new Date(v) : null)
// Owner is an actor object (or null for an abandoned house); flatten to columns.
const ownerFields = (owner) => ({
owner_serial: orNull(owner?.serial),
owner_acct: orNull(owner?.acct),
owner_name: orNull(owner?.name),
})
// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
// partial refresh only touches the fields it carries.
function onlineFields(data) {
@@ -180,23 +191,20 @@ async function listHousesForAccounts(accounts) {
// actor object (or null for an abandoned house).
async function upsertHouseRegistry(data) {
if (!data || !data.serial) return
const owner = data.owner || null
const fields = {
name: data.name ?? null,
owner_serial: owner ? owner.serial ?? null : null,
owner_acct: owner ? owner.acct ?? null : null,
owner_name: owner ? owner.name ?? null : null,
co_owners: data.coOwners ?? null,
friends: data.friends ?? null,
price: data.price ?? null,
decay: data.decay ?? null,
region: data.region ?? null,
map: data.map ?? null,
x: data.x ?? null,
y: data.y ?? null,
z: data.z ?? null,
built_on: data.builtOn ? new Date(data.builtOn) : null,
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
name: orNull(data.name),
...ownerFields(data.owner || null),
co_owners: orNull(data.coOwners),
friends: orNull(data.friends),
price: orNull(data.price),
decay: orNull(data.decay),
region: orNull(data.region),
map: orNull(data.map),
x: orNull(data.x),
y: orNull(data.y),
z: orNull(data.z),
built_on: toDate(data.builtOn),
last_refreshed: toDate(data.lastRefreshed),
in_registry: 1,
}
await db.upsertHouse(data.serial, fields)
@@ -222,15 +230,15 @@ async function listOnlineForAccounts(accounts) {
async function upsertChamp(ev) {
if (!ev || !ev.serial) return
await db.upsertChamp(ev.serial, {
category: ev.category ?? null,
type: ev.type ?? null,
name: ev.name ?? null,
status: ev.status ?? null,
category: orNull(ev.category),
type: orNull(ev.type),
name: orNull(ev.name),
status: orNull(ev.status),
active: ev.active ? 1 : 0,
map: ev.map ?? null,
x: ev.x ?? null,
y: ev.y ?? null,
z: ev.z ?? null,
map: orNull(ev.map),
x: orNull(ev.x),
y: orNull(ev.y),
z: orNull(ev.z),
boss_up: ev.bossUp ? 1 : 0,
payload: JSON.stringify(ev),
t: Number.isFinite(ev.t) ? ev.t : null,
@@ -280,18 +288,18 @@ async function upsertPage(ev) {
if (!pageId) return
const sender = ev.sender || {}
await db.upsertPage(pageId, {
type: ev.type ?? null,
sender_name: sender.name ?? null,
sender_acct: sender.acct ?? null,
web_id: sender.webId ?? null,
message: ev.message ?? null,
map: ev.map ?? null,
x: ev.x ?? null,
y: ev.y ?? null,
z: ev.z ?? null,
type: orNull(ev.type),
sender_name: orNull(sender.name),
sender_acct: orNull(sender.acct),
web_id: orNull(sender.webId),
message: orNull(ev.message),
map: orNull(ev.map),
x: orNull(ev.x),
y: orNull(ev.y),
z: orNull(ev.z),
sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
handled: ev.handled ? 1 : 0,
handler: ev.handler ?? null,
handler: orNull(ev.handler),
payload: JSON.stringify(ev),
})
}
@@ -406,19 +414,19 @@ async function listGuildsLedForAccounts(accounts) {
async function upsertGovernor(ev) {
if (!ev || !ev.city) return
await recordGovernorTransition(ev)
const gov = ev.governor || null
const elect = ev.governorElect || null
const gov = ev.governor
const elect = ev.governorElect
await db.upsertGovernor(ev.city, {
governor_serial: gov ? gov.serial ?? null : null,
governor_name: gov ? gov.name ?? null : null,
governor_acct: gov ? gov.acct ?? null : null,
governor_web_id: gov ? gov.webId ?? null : null,
elect_serial: elect ? elect.serial ?? null : null,
elect_name: elect ? elect.name ?? null : null,
elect_acct: elect ? elect.acct ?? null : null,
election_phase: ev.electionPhase ?? null,
candidates: ev.candidates ?? null,
auto_pick_at: ev.autoPickAt ? new Date(ev.autoPickAt) : null,
governor_serial: orNull(gov?.serial),
governor_name: orNull(gov?.name),
governor_acct: orNull(gov?.acct),
governor_web_id: orNull(gov?.webId),
elect_serial: orNull(elect?.serial),
elect_name: orNull(elect?.name),
elect_acct: orNull(elect?.acct),
election_phase: orNull(ev.electionPhase),
candidates: orNull(ev.candidates),
auto_pick_at: toDate(ev.autoPickAt),
payload: JSON.stringify(ev),
t: Number.isFinite(ev.t) ? ev.t : null,
})

View File

@@ -98,11 +98,10 @@ async function create({ slug, title, body, excerpt, categoryId, published, updat
return getBySlug(slug)
}
// Partial update — only keys present in `input` are written.
async function update(slug, input) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
// Map the partial `input` to DB columns (only keys present are written), and
// report the cleaned body for link rebuilding when the body changed. Split out of
// update() so that stays a flat sequence of write steps.
function mapUpdateFields(input, current) {
const fields = { updated_by: input.updatedBy ?? null }
let cleanForLinks = null
if ('title' in input) fields.title = input.title
@@ -116,7 +115,15 @@ async function update(slug, input) {
fields.published = input.published ? 1 : 0
if (input.published && !current.published_at) fields.published_at = new Date()
}
return { fields, cleanForLinks }
}
// Partial update — only keys present in `input` are written.
async function update(slug, input) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const { fields, cleanForLinks } = mapUpdateFields(input, current)
await wikiDb.updateBySlug(slug, fields)
if (Array.isArray(input.tags)) await syncTags(current.id, input.tags)
if (cleanForLinks != null) await rebuildLinks(current.id, cleanForLinks)