From f66bab122bbd168e10cd3adc28dffd86f05b01ca Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 20 Jul 2026 23:10:17 +0530 Subject: [PATCH] fix(scripts): harden apply-ltd-numbers/apply-name-fixes/import-employees DB resolution Same pre-ship audit fix as apply-onboarding-pipeline.ts and migrate-onboarding- templates.ts: replace the raw `DATABASE_URL ?? ''` read with the shared requireDbUrlForEnv() resolver, log the resolved engine/target before mutating, hard-fail instead of silently opening a throwaway SQLite file in production, and close the DB handle in a finally block. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/scripts/apply-ltd-numbers.ts | 55 ++++++++++++++---------- apps/hq/scripts/apply-name-fixes.ts | 51 ++++++++++++++--------- apps/hq/scripts/import-employees.ts | 62 +++++++++++++++++----------- 3 files changed, 103 insertions(+), 65 deletions(-) diff --git a/apps/hq/scripts/apply-ltd-numbers.ts b/apps/hq/scripts/apply-ltd-numbers.ts index 513c645..7c4c040 100644 --- a/apps/hq/scripts/apply-ltd-numbers.ts +++ b/apps/hq/scripts/apply-ltd-numbers.ts @@ -5,9 +5,10 @@ // Does NOT overwrite the name's spelling/casing — only appends the number. // LTD_JSON= DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-ltd-numbers.ts import { readFileSync } from 'node:fs' -import { openDb } from '../src/db' -import { openPgDb } from '../src/db-pg' +import { openDb, sqliteFilePath } from '../src/db' +import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg' import { updateClient } from '../src/repos-clients' +import type { DB } from '../src/db' interface Res { clientId: string; ltdNo: string; found: boolean } @@ -16,27 +17,39 @@ async function main() { if (path === undefined) throw new Error('Set LTD_JSON to the workflow result json path') const parsed = JSON.parse(readFileSync(path, 'utf8')) as { results?: Res[] } | Res[] const results = Array.isArray(parsed) ? parsed : (parsed.results ?? []) - const pgUrl = process.env['DATABASE_URL'] ?? '' - const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) - let applied = 0, already = 0, skipped = 0 - const seen = new Set() - for (const r of results) { - const ltd = (r.ltdNo ?? '').trim() - if (!r.found || ltd === '' || seen.has(r.clientId)) { skipped++; continue } - seen.add(r.clientId) - const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.clientId) - if (cur === undefined) { skipped++; continue } - // Already carries this number (bare, or "LTD. K 1028" with spaces/punct)? Don't duplicate. - // Compare with all non-alphanumerics stripped so "K 1028" matches "K1028". - const squish = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, '') - if (squish(cur.name).includes(squish(ltd))) { already++; continue } - const newName = `${cur.name} LTD NO. ${ltd}` - await updateClient(db, 'system', r.clientId, { name: newName }) - applied++ + // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and, + // unlike the server, this one-off script hard-fails rather than silently falling back to a + // throwaway SQLite file when NODE_ENV=production resolves no Postgres target. + const pgUrl = requireDbUrlForEnv() + if (pgUrl !== '') { + console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`) + } else { + console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`) + } + const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + try { + let applied = 0, already = 0, skipped = 0 + const seen = new Set() + for (const r of results) { + const ltd = (r.ltdNo ?? '').trim() + if (!r.found || ltd === '' || seen.has(r.clientId)) { skipped++; continue } + seen.add(r.clientId) + const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.clientId) + if (cur === undefined) { skipped++; continue } + // Already carries this number (bare, or "LTD. K 1028" with spaces/punct)? Don't duplicate. + // Compare with all non-alphanumerics stripped so "K 1028" matches "K1028". + const squish = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, '') + if (squish(cur.name).includes(squish(ltd))) { already++; continue } + const newName = `${cur.name} LTD NO. ${ltd}` + await updateClient(db, 'system', r.clientId, { name: newName }) + applied++ + } + // eslint-disable-next-line no-console + console.log(`LTD numbers: ${applied} appended, ${already} already present, ${skipped} skipped (not found / no number).`) + } finally { + await db.close() } - // eslint-disable-next-line no-console - console.log(`LTD numbers: ${applied} appended, ${already} already present, ${skipped} skipped (not found / no number).`) } main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/hq/scripts/apply-name-fixes.ts b/apps/hq/scripts/apply-name-fixes.ts index 15b6dbf..9c577e2 100644 --- a/apps/hq/scripts/apply-name-fixes.ts +++ b/apps/hq/scripts/apply-name-fixes.ts @@ -2,9 +2,10 @@ // client-name corrections from name-review.json via the audited updateClient path. // REVIEW_JSON= DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-name-fixes.ts import { readFileSync } from 'node:fs' -import { openDb } from '../src/db' -import { openPgDb } from '../src/db-pg' +import { openDb, sqliteFilePath } from '../src/db' +import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg' import { updateClient } from '../src/repos-clients' +import type { DB } from '../src/db' interface Row { id: string; current: string; suggested: string; changed: boolean } @@ -12,26 +13,38 @@ async function main() { const path = process.env['REVIEW_JSON'] if (path === undefined) throw new Error('Set REVIEW_JSON to the name-review.json path') const rows = JSON.parse(readFileSync(path, 'utf8')) as Row[] - const pgUrl = process.env['DATABASE_URL'] ?? '' - const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) - let applied = 0, skipped = 0 - for (const r of rows) { - if (!r.changed || r.suggested === r.current) continue - try { - // Guard: only touch a client whose stored name still matches what was reviewed. - const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.id) - if (cur === undefined || cur.name !== r.current) { skipped++; continue } - await updateClient(db, 'system', r.id, { name: r.suggested }) - applied++ - } catch (e) { - skipped++ - // eslint-disable-next-line no-console - console.error(` skip ${r.current}: ${e instanceof Error ? e.message : String(e)}`) + // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and, + // unlike the server, this one-off script hard-fails rather than silently falling back to a + // throwaway SQLite file when NODE_ENV=production resolves no Postgres target. + const pgUrl = requireDbUrlForEnv() + if (pgUrl !== '') { + console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`) + } else { + console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`) + } + const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + try { + let applied = 0, skipped = 0 + for (const r of rows) { + if (!r.changed || r.suggested === r.current) continue + try { + // Guard: only touch a client whose stored name still matches what was reviewed. + const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.id) + if (cur === undefined || cur.name !== r.current) { skipped++; continue } + await updateClient(db, 'system', r.id, { name: r.suggested }) + applied++ + } catch (e) { + skipped++ + // eslint-disable-next-line no-console + console.error(` skip ${r.current}: ${e instanceof Error ? e.message : String(e)}`) + } } + // eslint-disable-next-line no-console + console.log(`Name corrections: ${applied} applied, ${skipped} skipped.`) + } finally { + await db.close() } - // eslint-disable-next-line no-console - console.log(`Name corrections: ${applied} applied, ${skipped} skipped.`) } main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/hq/scripts/import-employees.ts b/apps/hq/scripts/import-employees.ts index 7c4198b..e41ac1d 100644 --- a/apps/hq/scripts/import-employees.ts +++ b/apps/hq/scripts/import-employees.ts @@ -3,9 +3,10 @@ // and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT. // Idempotent: skips a username/email that already exists. // DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts -import { openDb } from '../src/db' -import { openPgDb } from '../src/db-pg' +import { openDb, sqliteFilePath } from '../src/db' +import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg' import { createEmployee } from '../src/repos-employees' +import type { DB } from '../src/db' // From empmaster.xlsx (EMPNAME + ACTIVE_STAT + DESIGNATION code). Emails/phones were blank. const EMPLOYEES: { name: string; active: boolean; title?: string }[] = [ @@ -28,31 +29,42 @@ function slug(name: string): string { } async function main() { - // Same engine selection as the server: DATABASE_URL → Postgres, else SQLite at HQ_DATA_DIR. - const pgUrl = process.env['DATABASE_URL'] ?? '' - const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) - let added = 0, skipped = 0 - const summary: string[] = [] - for (const e of EMPLOYEES) { - const username = slug(e.name) - const password = `${username}@123` - const email = `${username}@sims.local` // placeholder (no real emails in the master) - try { - const emp = await createEmployee(db, 'system', { - email, username, displayName: e.name, role: 'staff', password, - mustChangePassword: true, ...(e.title !== undefined ? { title: e.title } : {}), - }) - // Set inactive employees inactive (createEmployee makes them active by default). - if (!e.active) await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, emp.id) - added++ - summary.push(` + ${e.name} → username: ${username} password: ${password} ${e.active ? '' : '(inactive)'}`) - } catch (err) { - skipped++ - summary.push(` - ${e.name} skipped: ${err instanceof Error ? err.message : String(err)}`) + // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and, + // unlike the server, this one-off script hard-fails rather than silently falling back to a + // throwaway SQLite file when NODE_ENV=production resolves no Postgres target. + const pgUrl = requireDbUrlForEnv() + if (pgUrl !== '') { + console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`) + } else { + console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`) + } + const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + try { + let added = 0, skipped = 0 + const summary: string[] = [] + for (const e of EMPLOYEES) { + const username = slug(e.name) + const password = `${username}@123` + const email = `${username}@sims.local` // placeholder (no real emails in the master) + try { + const emp = await createEmployee(db, 'system', { + email, username, displayName: e.name, role: 'staff', password, + mustChangePassword: true, ...(e.title !== undefined ? { title: e.title } : {}), + }) + // Set inactive employees inactive (createEmployee makes them active by default). + if (!e.active) await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, emp.id) + added++ + summary.push(` + ${e.name} → username: ${username} password: ${password} ${e.active ? '' : '(inactive)'}`) + } catch (err) { + skipped++ + summary.push(` - ${e.name} skipped: ${err instanceof Error ? err.message : String(err)}`) + } } + // eslint-disable-next-line no-console + console.log(`Employee import: ${added} added, ${skipped} skipped.\n${summary.join('\n')}`) + } finally { + await db.close() } - // eslint-disable-next-line no-console - console.log(`Employee import: ${added} added, ${skipped} skipped.\n${summary.join('\n')}`) } main().catch((e) => { console.error(e); process.exit(1) })