chore(quality): resolve SonarQube code smells across website
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:
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user