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

@@ -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)