// apps/hq/src/repos-employees.ts — the Employee entity (backed by staff_user; D16). // staff_user IS the employee table: renaming it would churn session.staff_id, // interaction.staff_id and audit entity names for zero gain. "Employee" lives in // the repo/API/UI vocabulary only. Password columns never leave this module. import { hashPin, verifyPin } from '@sims/auth' import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' export type EmployeeRole = 'owner' | 'manager' | 'staff' const ROLES: readonly EmployeeRole[] = ['owner', 'manager', 'staff'] as const export interface Employee { id: string; username: string; email: string; displayName: string; role: EmployeeRole; active: boolean phone: string | null; title: string | null /** D34: true while the employee still has their invited default password (pending first change). */ mustChangePassword: boolean } interface EmployeeRow { id: string; username: string | null; email: string; display_name: string; role: string; active: number phone: string | null; title: string | null; must_change_password: number } // Explicit column list — pw_salt/pw_hash must never be selected here. const COLS = 'id, username, email, display_name, role, active, phone, title, must_change_password' function toEmployee(r: EmployeeRow): Employee { return { id: r.id, username: r.username ?? r.email, email: r.email, displayName: r.display_name, role: r.role as EmployeeRole, active: r.active === 1, phone: r.phone, title: r.title, mustChangePassword: r.must_change_password === 1, } } function assertRole(role: string): asserts role is EmployeeRole { if (!ROLES.includes(role as EmployeeRole)) { throw new Error(`Invalid role '${role}' — must be one of ${ROLES.join(', ')}`) } } /** Console users are a small bounded set (not a growable list) — returned whole, with a count for the UI. */ export async function listEmployees(db: DB): Promise { const rows = await db.all( `SELECT ${COLS} FROM staff_user ORDER BY display_name`, ) return rows.map(toEmployee) } export async function getEmployee(db: DB, id: string): Promise { const row = await db.get(`SELECT ${COLS} FROM staff_user WHERE id=?`, id) return row ? toEmployee(row) : null } /** True when at least one ACTIVE owner other than `excludeId` exists. */ async function otherActiveOwnerExists(db: DB, excludeId: string): Promise { const row = (await db.get<{ n: number }>( `SELECT COUNT(*) AS n FROM staff_user WHERE role='owner' AND active=1 AND id != ?`, excludeId, ))! return row.n > 0 } export async function createEmployee(db: DB, userId: string, input: { email: string; displayName: string; role: EmployeeRole; password: string username?: string // D25: login identifier; defaults to email when not given phone?: string; title?: string mustChangePassword?: boolean // D34: invited with a default password to change }): Promise { assertRole(input.role) if (input.password.length < 8) throw new Error('Password must be at least 8 characters') const email = input.email.trim().toLowerCase() if (email === '') throw new Error('Email is required') if (input.displayName.trim() === '') throw new Error('Name is required') const username = (input.username ?? input.email).trim().toLowerCase() if (username === '') throw new Error('Username is required') const id = uuidv7() const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied const mcp = input.mustChangePassword === true ? 1 : 0 return db.transaction(async () => { // Friendly, engine-neutral pre-checks (login is by username; email stays a field). if (await db.get(`SELECT 1 FROM staff_user WHERE username=?`, username) !== undefined) { throw new Error('Username already in use') } if (await db.get(`SELECT 1 FROM staff_user WHERE email=?`, email) !== undefined) { throw new Error('Email already in use') } await db.run( `INSERT INTO staff_user (id, username, email, display_name, role, phone, title, pw_salt, pw_hash, must_change_password) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, username, email, input.displayName, input.role, input.phone?.trim() || null, input.title?.trim() || null, salt, hash, mcp, ) await writeAudit(db, userId, 'create', 'staff_user', id, undefined, { username, email, role: input.role }) return (await getEmployee(db, id))! }) } export async function updateEmployee(db: DB, userId: string, id: string, patch: { displayName?: string; role?: EmployeeRole; phone?: string; title?: string }): Promise { return db.transaction(async () => { const before = await getEmployee(db, id) if (!before) throw new Error('Employee not found') if (patch.role !== undefined) { assertRole(patch.role) // Demoting the last active owner would lock everyone out of owner-gated routes. if (before.role === 'owner' && before.active && patch.role !== 'owner' && !(await otherActiveOwnerExists(db, id))) { throw new Error('Cannot demote the last active owner') } } const sets: string[] = [] const args: unknown[] = [] if (patch.displayName !== undefined) { if (patch.displayName.trim() === '') throw new Error('Name is required') sets.push('display_name=?'); args.push(patch.displayName) } if (patch.role !== undefined) { sets.push('role=?'); args.push(patch.role) } // Contact fields are optional text; an emptied field stores NULL, not ''. if (patch.phone !== undefined) { sets.push('phone=?'); args.push(patch.phone.trim() === '' ? null : patch.phone.trim()) } if (patch.title !== undefined) { sets.push('title=?'); args.push(patch.title.trim() === '' ? null : patch.title.trim()) } // An empty patch is a caller error — succeeding silently would write a // no-op audit row and tell the caller a change landed when nothing did. if (sets.length === 0) throw new Error('Nothing to update') args.push(id) await db.run(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`, ...args) const after = (await getEmployee(db, id))! await writeAudit(db, userId, 'update', 'staff_user', id, before, after) return after }) } /** * Self-service password change (D18 WS-E): requires the CURRENT password, re-hashes, * and kills every OTHER session in the same transaction — the session performing the * change survives (currentToken), everything else dies with the old credential. * Audited as change_own_password; hashes never leave this module or reach the audit. */ export async function changeOwnPassword( db: DB, userId: string, currentPassword: string, newPassword: string, currentToken: string, ): Promise { if (newPassword.length < 8) throw new Error('Password must be at least 8 characters') const row = await db.get<{ pw_salt: string; pw_hash: string }>( `SELECT pw_salt, pw_hash FROM staff_user WHERE id=? AND active=1`, userId) if (row === undefined) throw new Error('Employee not found') if (!verifyPin(currentPassword, { salt: row.pw_salt, hash: row.pw_hash })) { throw new Error('Current password is incorrect') } const { salt, hash } = hashPin(newPassword) await db.transaction(async () => { // Clearing must_change_password: the invited default password has now been replaced. await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=?, must_change_password=0 WHERE id=?`, salt, hash, userId) await db.run(`DELETE FROM session WHERE staff_id=? AND token != ?`, userId, currentToken) await writeAudit(db, userId, 'change_own_password', 'staff_user', userId) }) } /** Reset an employee's password. Audits the action; never logs the hash. * Existing sessions are purged in the same transaction — a reset is the natural * "lock out the old credential" action, so a stolen token must not outlive it. */ export async function setEmployeePassword(db: DB, userId: string, id: string, password: string): Promise { if (password.length < 8) throw new Error('Password must be at least 8 characters') const { salt, hash } = hashPin(password) await db.transaction(async () => { const before = await getEmployee(db, id) if (!before) throw new Error('Employee not found') await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, id) await db.run(`DELETE FROM session WHERE staff_id=?`, id) await writeAudit(db, userId, 'reset_password', 'staff_user', id) }) } /** Deactivation takes effect immediately: sessions are purged in the same transaction. */ export async function deactivateEmployee(db: DB, userId: string, id: string): Promise { if (id === userId) throw new Error('Cannot deactivate yourself') return db.transaction(async () => { const before = await getEmployee(db, id) if (!before) throw new Error('Employee not found') if (!before.active) return before // already inactive — idempotent if (before.role === 'owner' && !(await otherActiveOwnerExists(db, id))) { throw new Error('Cannot deactivate the last active owner') } await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, id) await db.run(`DELETE FROM session WHERE staff_id=?`, id) const after = (await getEmployee(db, id))! await writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after) return after }) } export async function reactivateEmployee(db: DB, userId: string, id: string): Promise { return db.transaction(async () => { const before = await getEmployee(db, id) if (!before) throw new Error('Employee not found') if (before.active) return before // already active — idempotent await db.run(`UPDATE staff_user SET active=1 WHERE id=?`, id) const after = (await getEmployee(db, id))! await writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after) return after }) }