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) <noreply@anthropic.com>
main
Thomas Joise 1 day ago
parent 9457b0f145
commit f66bab122b

@ -5,9 +5,10 @@
// Does NOT overwrite the name's spelling/casing — only appends the number. // Does NOT overwrite the name's spelling/casing — only appends the number.
// LTD_JSON=<results.json> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-ltd-numbers.ts // LTD_JSON=<results.json> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-ltd-numbers.ts
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { openDb } from '../src/db' import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb } from '../src/db-pg' import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { updateClient } from '../src/repos-clients' import { updateClient } from '../src/repos-clients'
import type { DB } from '../src/db'
interface Res { clientId: string; ltdNo: string; found: boolean } 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') 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 parsed = JSON.parse(readFileSync(path, 'utf8')) as { results?: Res[] } | Res[]
const results = Array.isArray(parsed) ? parsed : (parsed.results ?? []) 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 // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
const seen = new Set<string>() // unlike the server, this one-off script hard-fails rather than silently falling back to a
for (const r of results) { // throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
const ltd = (r.ltdNo ?? '').trim() const pgUrl = requireDbUrlForEnv()
if (!r.found || ltd === '' || seen.has(r.clientId)) { skipped++; continue } if (pgUrl !== '') {
seen.add(r.clientId) console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.clientId) } else {
if (cur === undefined) { skipped++; continue } console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
// 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 db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
const squish = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]/g, '') try {
if (squish(cur.name).includes(squish(ltd))) { already++; continue } let applied = 0, already = 0, skipped = 0
const newName = `${cur.name} LTD NO. ${ltd}` const seen = new Set<string>()
await updateClient(db, 'system', r.clientId, { name: newName }) for (const r of results) {
applied++ 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) }) main().catch((e) => { console.error(e); process.exit(1) })

@ -2,9 +2,10 @@
// client-name corrections from name-review.json via the audited updateClient path. // client-name corrections from name-review.json via the audited updateClient path.
// REVIEW_JSON=<path> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-name-fixes.ts // REVIEW_JSON=<path> DATABASE_URL=postgres://… npx tsx apps/hq/scripts/apply-name-fixes.ts
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { openDb } from '../src/db' import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb } from '../src/db-pg' import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { updateClient } from '../src/repos-clients' import { updateClient } from '../src/repos-clients'
import type { DB } from '../src/db'
interface Row { id: string; current: string; suggested: string; changed: boolean } interface Row { id: string; current: string; suggested: string; changed: boolean }
@ -12,26 +13,38 @@ async function main() {
const path = process.env['REVIEW_JSON'] const path = process.env['REVIEW_JSON']
if (path === undefined) throw new Error('Set REVIEW_JSON to the name-review.json path') 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 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 // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
for (const r of rows) { // unlike the server, this one-off script hard-fails rather than silently falling back to a
if (!r.changed || r.suggested === r.current) continue // throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
try { const pgUrl = requireDbUrlForEnv()
// Guard: only touch a client whose stored name still matches what was reviewed. if (pgUrl !== '') {
const cur = await db.get<{ name: string }>(`SELECT name FROM client WHERE id=?`, r.id) console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
if (cur === undefined || cur.name !== r.current) { skipped++; continue } } else {
await updateClient(db, 'system', r.id, { name: r.suggested }) console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
applied++ }
} catch (e) { const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
skipped++ try {
// eslint-disable-next-line no-console let applied = 0, skipped = 0
console.error(` skip ${r.current}: ${e instanceof Error ? e.message : String(e)}`) 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) }) main().catch((e) => { console.error(e); process.exit(1) })

@ -3,9 +3,10 @@
// and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT. // and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT.
// Idempotent: skips a username/email that already exists. // Idempotent: skips a username/email that already exists.
// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts // DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts
import { openDb } from '../src/db' import { openDb, sqliteFilePath } from '../src/db'
import { openPgDb } from '../src/db-pg' import { openPgDb, pgTargetDescription, requireDbUrlForEnv } from '../src/db-pg'
import { createEmployee } from '../src/repos-employees' import { createEmployee } from '../src/repos-employees'
import type { DB } from '../src/db'
// From empmaster.xlsx (EMPNAME + ACTIVE_STAT + DESIGNATION code). Emails/phones were blank. // From empmaster.xlsx (EMPNAME + ACTIVE_STAT + DESIGNATION code). Emails/phones were blank.
const EMPLOYEES: { name: string; active: boolean; title?: string }[] = [ const EMPLOYEES: { name: string; active: boolean; title?: string }[] = [
@ -28,31 +29,42 @@ function slug(name: string): string {
} }
async function main() { async function main() {
// Same engine selection as the server: DATABASE_URL → Postgres, else SQLite at HQ_DATA_DIR. // Pre-ship audit FIX A: the SAME resolver server.ts uses (never re-derived locally) — and,
const pgUrl = process.env['DATABASE_URL'] ?? '' // unlike the server, this one-off script hard-fails rather than silently falling back to a
const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) // throwaway SQLite file when NODE_ENV=production resolves no Postgres target.
let added = 0, skipped = 0 const pgUrl = requireDbUrlForEnv()
const summary: string[] = [] if (pgUrl !== '') {
for (const e of EMPLOYEES) { console.log(`[db] engine: postgres ${pgTargetDescription(pgUrl)}`)
const username = slug(e.name) } else {
const password = `${username}@123` console.log(`[db] engine: sqlite at ${sqliteFilePath(process.env['HQ_DATA_DIR'])}`)
const email = `${username}@sims.local` // placeholder (no real emails in the master) }
try { const db: DB = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR'])
const emp = await createEmployee(db, 'system', { try {
email, username, displayName: e.name, role: 'staff', password, let added = 0, skipped = 0
mustChangePassword: true, ...(e.title !== undefined ? { title: e.title } : {}), const summary: string[] = []
}) for (const e of EMPLOYEES) {
// Set inactive employees inactive (createEmployee makes them active by default). const username = slug(e.name)
if (!e.active) await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, emp.id) const password = `${username}@123`
added++ const email = `${username}@sims.local` // placeholder (no real emails in the master)
summary.push(` + ${e.name} → username: ${username} password: ${password} ${e.active ? '' : '(inactive)'}`) try {
} catch (err) { const emp = await createEmployee(db, 'system', {
skipped++ email, username, displayName: e.name, role: 'staff', password,
summary.push(` - ${e.name} skipped: ${err instanceof Error ? err.message : String(err)}`) 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) }) main().catch((e) => { console.error(e); process.exit(1) })

Loading…
Cancel
Save