You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-employees.ts

189 lines
8.8 KiB
TypeScript

// 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; email: string; displayName: string; role: EmployeeRole; active: boolean
phone: string | null; title: string | null
}
interface EmployeeRow {
id: string; email: string; display_name: string; role: string; active: number
phone: string | null; title: string | null
}
// Explicit column list — pw_salt/pw_hash must never be selected here.
const COLS = 'id, email, display_name, role, active, phone, title'
function toEmployee(r: EmployeeRow): Employee {
return {
id: r.id, email: r.email, displayName: r.display_name,
role: r.role as EmployeeRole, active: r.active === 1,
phone: r.phone, title: r.title,
}
}
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<Employee[]> {
const rows = await db.all<EmployeeRow>(
`SELECT ${COLS} FROM staff_user ORDER BY display_name`,
)
return rows.map(toEmployee)
}
export async function getEmployee(db: DB, id: string): Promise<Employee | null> {
const row = await db.get<EmployeeRow>(`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<boolean> {
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
}): Promise<Employee> {
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 id = uuidv7()
const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied
return db.transaction(async () => {
// Pre-check the UNIQUE(email) so the caller gets a friendly, engine-neutral
// message instead of raw driver text (SQLite and Postgres word it differently).
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, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
id, email, input.displayName, input.role, salt, hash,
)
await writeAudit(db, userId, 'create', 'staff_user', id, undefined, { 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<Employee> {
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<void> {
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 () => {
await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? 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<void> {
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<Employee> {
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<Employee> {
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
})
}