From 1af7b87aab12f6dcf001b3103f90cace4213c0fb Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 01:04:55 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq):=20employee=20foundation=20=E2=80=94?= =?UTF-8?q?=20manager=20role,=20employee=20repo,=20session=20hardening=20(?= =?UTF-8?q?phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rebuildTable helper: guarded, txn-wrapped CHECK-constraint rebuild (SQLite-only path) - staff_user.role CHECK widened to owner|manager|staff (SCHEMA + migrate for old DBs) - repos-employees.ts: list/get/create/update/setPassword/deactivate/reactivate, audited in-txn, password columns never selected, last-active-owner + self guards - verifySession requires active=1; deactivation purges sessions in the same txn - 9 tests (apps/hq/test/employees.test.ts); suite now 186 green Co-Authored-By: Claude Fable 5 --- .superpowers/brainstorm/.last-port | 1 + .superpowers/brainstorm/.last-token | 1 + .../content/module-roster.html | 94 ++++++++++++ .../content/quotations-pipeline.html | 141 +++++++++++++++++ .../content/swarm-running.html | 8 + .../4011-1784227603/state/server-info | 1 + .../4011-1784227603/state/server-instance-id | 1 + .../4011-1784227603/state/server.pid | 1 + apps/hq/src/auth.ts | 5 +- apps/hq/src/db.ts | 42 ++++- apps/hq/src/repos-employees.ts | 145 ++++++++++++++++++ apps/hq/test/employees.test.ts | 130 ++++++++++++++++ 12 files changed, 567 insertions(+), 3 deletions(-) create mode 100644 .superpowers/brainstorm/.last-port create mode 100644 .superpowers/brainstorm/.last-token create mode 100644 .superpowers/brainstorm/4011-1784227603/content/module-roster.html create mode 100644 .superpowers/brainstorm/4011-1784227603/content/quotations-pipeline.html create mode 100644 .superpowers/brainstorm/4011-1784227603/content/swarm-running.html create mode 100644 .superpowers/brainstorm/4011-1784227603/state/server-info create mode 100644 .superpowers/brainstorm/4011-1784227603/state/server-instance-id create mode 100644 .superpowers/brainstorm/4011-1784227603/state/server.pid create mode 100644 apps/hq/src/repos-employees.ts create mode 100644 apps/hq/test/employees.test.ts diff --git a/.superpowers/brainstorm/.last-port b/.superpowers/brainstorm/.last-port new file mode 100644 index 0000000..510c433 --- /dev/null +++ b/.superpowers/brainstorm/.last-port @@ -0,0 +1 @@ +65052 \ No newline at end of file diff --git a/.superpowers/brainstorm/.last-token b/.superpowers/brainstorm/.last-token new file mode 100644 index 0000000..468d157 --- /dev/null +++ b/.superpowers/brainstorm/.last-token @@ -0,0 +1 @@ +e0f352cd87dfab994f7622c297edfe2cb1118222bec860b092fa39c362e84090 \ No newline at end of file diff --git a/.superpowers/brainstorm/4011-1784227603/content/module-roster.html b/.superpowers/brainstorm/4011-1784227603/content/module-roster.html new file mode 100644 index 0000000..1c39c2e --- /dev/null +++ b/.superpowers/brainstorm/4011-1784227603/content/module-roster.html @@ -0,0 +1,94 @@ +

“Who’s using module X?” — the answer screen

+

Pick a module on the left → instantly see every client on it. Two things you asked for in one place: a notify list (support/impact) and what it earns + when it renews (revenue). It lives inside the Modules page you already have — nothing new to learn.

+ +
+
Modules → RTGS / NEFT roster
+
+
+ + +
+
Modules
+
Core Banking 128
+
RTGS / NEFT 47
+
SMS Alerts 210
+
Mobile App 36
+
ATM 54
+
Passbook 89
+
+ + +
+
+
+
RTGS / NEFT
+
47 clients · ₹3,80,000 / yr · 2 renew in next 60 days
+
+
+ ✉ Notify all 47 + ⭳ Export CSV +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClientDistrictOn sincePaysNext renewal
Sahakari Bank PunePuneApr 2022₹9,00012 Aug 2026 · 26d
Janata Credit SocietySangliSep 2020₹7,50001 Sep 2026 · 46d
Deccan MercantilePuneJan 2021₹12,00003 Jan 2027
Kolhapur Urban CoopKolhapurJun 2023₹9,00015 Jun 2027
Pratibha NagariSataraMar 2024₹9,00020 Mar 2027
…42 more · list paginates (never silently cut)
+
+ +
+
+
+ +

Does this answer it the way you need? Click the emphasis you want:

+
+
+
+
+

This is exactly it

+

Notify list + revenue + renewals together. Build as shown.

+
+
+
+
S
+
+

Support-first

+

Make “Notify all / Export” the hero — for pushing a module update or outage notice fast.

+
+
+
+
+
+

Money-first

+

Lead with revenue & upcoming renewals per module (totals up top, renewals sorted first).

+
+
+
diff --git a/.superpowers/brainstorm/4011-1784227603/content/quotations-pipeline.html b/.superpowers/brainstorm/4011-1784227603/content/quotations-pipeline.html new file mode 100644 index 0000000..f734439 --- /dev/null +++ b/.superpowers/brainstorm/4011-1784227603/content/quotations-pipeline.html @@ -0,0 +1,141 @@ +

Quotations Pipeline — pick the view that fits how you chase quotes

+

Same data, two ways to see “what needs following up today.” This is the screen that gets messed up right now — we’ll build whichever one you pick. Every quote shows its owner (employee) and its next nudge.

+ +
+ + +
+
A · Pipeline board — the whole funnel at a glance
+
+
+ +
+
Enquiry · 2
+
+
Kolhapur Urban Coop
+
Mobile App · quote not sent
+
+ Anil + 3d +
+
+
+
Pratibha Nagari
+
RTGS/NEFT
+
+ Rahul + 1d +
+
+
+ +
+
Quoted · Waiting · 3
+
+
Sahakari Bank Pune
+
Core Banking · ₹1,20,000
+
+ Priya + chase overdue · 9d +
+
+
+
Janata Credit Society
+
SMS Alerts · ₹85,000
+
+ Rahul + nudge in 2d +
+
+
+
Deccan Mercantile
+
Core Banking · ₹2,40,000
+
+ Priya + final nudge · 15d +
+
+
+ +
+
Won · 1
+
+
Shivneri Sahakari
+
Mobile App · ₹60,000
+
+ → convert to invoice +
+
+
+ +
+
+
+ + +
+
B · Chase-list — one ranked “do this now” list
+
+
+ All quotes + Mine + Overdue + Owner ▾ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClientStageAmountOwnerNext action
Deccan MercantileQuoted₹2,40,000PriyaFinal nudge · 15d
Sahakari Bank PuneQuoted₹1,20,000PriyaChase overdue · 9d
Janata Credit SocietyQuoted₹85,000RahulNudge in 2d
Kolhapur Urban CoopEnquiryAnilSend quote
Shivneri SahakariWon₹60,000RahulConvert to invoice
+
+
+ +
+ +
+
+
A
+
+

Pipeline board

+

See every quote by stage, drag a card as it moves Enquiry → Quoted → Won. Best for seeing the whole funnel’s shape.

+
+
+
+
B
+
+

Chase-list

+

One ranked list — most overdue on top, colour-coded, “next action” spelled out. Less clicking, more doing. Fewer things to mess up.

+
+
+
+
A+B
+
+

Board + a “needs chasing” strip

+

The board as the main view, with the overdue chase-list pinned at the top as a to-do strip. Slightly more to build.

+
+
+
diff --git a/.superpowers/brainstorm/4011-1784227603/content/swarm-running.html b/.superpowers/brainstorm/4011-1784227603/content/swarm-running.html new file mode 100644 index 0000000..145f2d0 --- /dev/null +++ b/.superpowers/brainstorm/4011-1784227603/content/swarm-running.html @@ -0,0 +1,8 @@ +
+
+
🐝
+

Design swarm is running…

+

9 agents: 5 designing each piece in parallel → 3 stress-testing → 1 synthesizing the spec + roadmap.
Back in the terminal in a few minutes with the full design for your approval.

+

Locked: employee foundation · chase-list pipeline · module roster · escalating quote reminders · one-click proforma→invoice

+
+
diff --git a/.superpowers/brainstorm/4011-1784227603/state/server-info b/.superpowers/brainstorm/4011-1784227603/state/server-info new file mode 100644 index 0000000..df2a98e --- /dev/null +++ b/.superpowers/brainstorm/4011-1784227603/state/server-info @@ -0,0 +1 @@ +{"type":"server-started","port":65052,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:65052/?key=e0f352cd87dfab994f7622c297edfe2cb1118222bec860b092fa39c362e84090","screen_dir":"C:\\SiMS\\hq\\.superpowers\\brainstorm\\4011-1784227603\\content","state_dir":"C:\\SiMS\\hq\\.superpowers\\brainstorm\\4011-1784227603\\state","idle_timeout_ms":14400000} diff --git a/.superpowers/brainstorm/4011-1784227603/state/server-instance-id b/.superpowers/brainstorm/4011-1784227603/state/server-instance-id new file mode 100644 index 0000000..1f6ec16 --- /dev/null +++ b/.superpowers/brainstorm/4011-1784227603/state/server-instance-id @@ -0,0 +1 @@ +65410f13a14f7a21cd6975494bda8c56b77ef1fdd0b00415 diff --git a/.superpowers/brainstorm/4011-1784227603/state/server.pid b/.superpowers/brainstorm/4011-1784227603/state/server.pid new file mode 100644 index 0000000..178dccd --- /dev/null +++ b/.superpowers/brainstorm/4011-1784227603/state/server.pid @@ -0,0 +1 @@ +4027 diff --git a/apps/hq/src/auth.ts b/apps/hq/src/auth.ts index 6d75b34..daf5b95 100644 --- a/apps/hq/src/auth.ts +++ b/apps/hq/src/auth.ts @@ -9,7 +9,7 @@ import type { DB } from './db' const SESSION_DAYS = 14 export function createStaff(db: DB, input: { - email: string; displayName: string; role: 'owner' | 'staff'; password: string + email: string; displayName: string; role: 'owner' | 'manager' | 'staff'; password: string }): { id: string } { if (input.password.length < 8) throw new Error('Password must be at least 8 characters') const id = uuidv7() @@ -35,9 +35,10 @@ export function login(db: DB, email: string, password: string): } export function verifySession(db: DB, token: string): { id: string; role: string } | null { + // u.active=1: a deactivated employee's unexpired token must die immediately. const row = db.prepare( `SELECT s.staff_id AS id, u.role FROM session s JOIN staff_user u ON u.id = s.staff_id - WHERE s.token=? AND s.expires_at > ?`, + WHERE s.token=? AND s.expires_at > ? AND u.active = 1`, ).get(token, new Date().toISOString()) as { id: string; role: string } | undefined return row ?? null } diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 2c3a733..2bbef72 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -8,7 +8,7 @@ export type DB = Database.Database const SCHEMA = ` CREATE TABLE IF NOT EXISTS staff_user ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('owner','staff')), + role TEXT NOT NULL CHECK (role IN ('owner','manager','staff')), pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS session ( @@ -180,4 +180,44 @@ function migrate(db: DB): void { if (!emailCols.some((c) => c.name === 'bounced')) { db.exec(`ALTER TABLE email_log ADD COLUMN bounced INTEGER NOT NULL DEFAULT 0`) } + rebuildStaffUserRoleCheck(db) +} + +/** + * Rebuild a table whose CHECK constraint must change — SQLite cannot ALTER a CHECK in + * place, so the table is recreated and rows copied with an explicit column list. + * Guarded: a no-op unless `guardToken` (a fragment of the OLD constraint) is still + * present in the live DDL, so fresh DBs (born correct from SCHEMA) and re-runs skip it. + * The whole rebuild runs in one transaction (crash-safe). + * SQLite-only migration path — on Postgres (prod, D15) this is a one-line + * ALTER TABLE … DROP/ADD CONSTRAINT in the prod migration set. + */ +export function rebuildTable( + db: DB, table: string, newDdl: string, guardToken: string, columns: string[], +): void { + const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`) + .get(table) as { sql: string } | undefined + if (!row || !row.sql.includes(guardToken)) return + const cols = columns.join(', ') + db.transaction(() => { + db.exec(newDdl) + db.exec(`INSERT INTO ${table}__new (${cols}) SELECT ${cols} FROM ${table}`) + db.exec(`DROP TABLE ${table}`) + db.exec(`ALTER TABLE ${table}__new RENAME TO ${table}`) + })() +} + +/** Widen staff_user.role CHECK to allow 'manager' on DBs created before the employee slice. */ +export function rebuildStaffUserRoleCheck(db: DB): void { + rebuildTable( + db, + 'staff_user', + `CREATE TABLE staff_user__new ( + id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner','manager','staff')), + pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 +)`, + `('owner','staff')`, + ['id', 'email', 'display_name', 'role', 'pw_salt', 'pw_hash', 'active'], + ) } diff --git a/apps/hq/src/repos-employees.ts b/apps/hq/src/repos-employees.ts new file mode 100644 index 0000000..4f7bc9e --- /dev/null +++ b/apps/hq/src/repos-employees.ts @@ -0,0 +1,145 @@ +// 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 } 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 +} + +interface EmployeeRow { id: string; email: string; display_name: string; role: string; active: number } + +// Explicit column list — pw_salt/pw_hash must never be selected here. +const COLS = 'id, email, display_name, role, active' + +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, + } +} + +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 function listEmployees(db: DB): Employee[] { + const rows = db.prepare( + `SELECT ${COLS} FROM staff_user ORDER BY display_name`, + ).all() as EmployeeRow[] + return rows.map(toEmployee) +} + +export function getEmployee(db: DB, id: string): Employee | null { + const row = db.prepare(`SELECT ${COLS} FROM staff_user WHERE id=?`).get(id) as EmployeeRow | undefined + return row ? toEmployee(row) : null +} + +/** True when at least one ACTIVE owner other than `excludeId` exists. */ +function otherActiveOwnerExists(db: DB, excludeId: string): boolean { + const row = db.prepare( + `SELECT COUNT(*) AS n FROM staff_user WHERE role='owner' AND active=1 AND id != ?`, + ).get(excludeId) as { n: number } + return row.n > 0 +} + +export function createEmployee(db: DB, userId: string, input: { + email: string; displayName: string; role: EmployeeRole; password: string +}): 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(() => { + db.prepare( + `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, email, input.displayName, input.role, salt, hash) + writeAudit(db, userId, 'create', 'staff_user', id, undefined, { email, role: input.role }) + return getEmployee(db, id)! + })() +} + +export function updateEmployee(db: DB, userId: string, id: string, patch: { + displayName?: string; role?: EmployeeRole +}): Employee { + return db.transaction(() => { + const before = 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' && !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) } + if (sets.length > 0) { + args.push(id) + db.prepare(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getEmployee(db, id)! + writeAudit(db, userId, 'update', 'staff_user', id, before, after) + return after + })() +} + +/** Reset an employee's password. Audits the action; never logs the hash. */ +export function setEmployeePassword(db: DB, userId: string, id: string, password: string): void { + if (password.length < 8) throw new Error('Password must be at least 8 characters') + const { salt, hash } = hashPin(password) + db.transaction(() => { + const before = getEmployee(db, id) + if (!before) throw new Error('Employee not found') + db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, id) + writeAudit(db, userId, 'reset_password', 'staff_user', id) + })() +} + +/** Deactivation takes effect immediately: sessions are purged in the same transaction. */ +export function deactivateEmployee(db: DB, userId: string, id: string): Employee { + if (id === userId) throw new Error('Cannot deactivate yourself') + return db.transaction(() => { + const before = getEmployee(db, id) + if (!before) throw new Error('Employee not found') + if (!before.active) return before // already inactive — idempotent + if (before.role === 'owner' && !otherActiveOwnerExists(db, id)) { + throw new Error('Cannot deactivate the last active owner') + } + db.prepare(`UPDATE staff_user SET active=0 WHERE id=?`).run(id) + db.prepare(`DELETE FROM session WHERE staff_id=?`).run(id) + const after = getEmployee(db, id)! + writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after) + return after + })() +} + +export function reactivateEmployee(db: DB, userId: string, id: string): Employee { + return db.transaction(() => { + const before = getEmployee(db, id) + if (!before) throw new Error('Employee not found') + if (before.active) return before // already active — idempotent + db.prepare(`UPDATE staff_user SET active=1 WHERE id=?`).run(id) + const after = getEmployee(db, id)! + writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after) + return after + })() +} diff --git a/apps/hq/test/employees.test.ts b/apps/hq/test/employees.test.ts new file mode 100644 index 0000000..fb5547d --- /dev/null +++ b/apps/hq/test/employees.test.ts @@ -0,0 +1,130 @@ +// apps/hq/test/employees.test.ts — Phase 1: employee foundation (D16) +import Database from 'better-sqlite3' +import { describe, it, expect } from 'vitest' +import { openDb, rebuildStaffUserRoleCheck, type DB } from '../src/db' +import { createStaff, login, verifySession } from '../src/auth' +import { + createEmployee, deactivateEmployee, getEmployee, listEmployees, + reactivateEmployee, setEmployeePassword, updateEmployee, +} from '../src/repos-employees' +import { listAudit } from '../src/audit' + +function withOwner(): { db: DB; ownerId: string } { + const db = openDb(':memory:') + const { id } = createStaff(db, { + email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9', + }) + return { db, ownerId: id } +} + +describe('employee foundation', () => { + it('creates manager and staff; list never exposes password columns', () => { + const { db, ownerId } = withOwner() + createEmployee(db, ownerId, { email: 'M@x.co', displayName: 'Mgr', role: 'manager', password: 'password1' }) + createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) + const all = listEmployees(db) + expect(all).toHaveLength(3) + expect(all.map((e) => e.role).sort()).toEqual(['manager', 'owner', 'staff']) + expect(all.find((e) => e.displayName === 'Mgr')!.email).toBe('m@x.co') // lowercased + for (const e of all) { + expect(e).not.toHaveProperty('pw_salt') + expect(e).not.toHaveProperty('pw_hash') + } + }) + + it('rejects a bad role and a short password in the repo', () => { + const { db, ownerId } = withOwner() + expect(() => + createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'admin' as never, password: 'password1' }), + ).toThrow(/role/i) + expect(() => + createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'staff', password: 'short' }), + ).toThrow(/8/) + }) + + it('deactivation kills the live session immediately and purges its rows', () => { + const { db, ownerId } = withOwner() + const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) + const session = login(db, 's@x.co', 'password2')! + expect(verifySession(db, session.token)).toMatchObject({ id: emp.id }) + deactivateEmployee(db, ownerId, emp.id) + expect(verifySession(db, session.token)).toBeNull() + const rows = db.prepare(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`).get(emp.id) as { n: number } + expect(rows.n).toBe(0) + }) + + it('guards: last active owner cannot be demoted or deactivated; self-deactivation rejected', () => { + const { db, ownerId } = withOwner() + expect(() => updateEmployee(db, ownerId, ownerId, { role: 'staff' })).toThrow(/last active owner/) + expect(() => deactivateEmployee(db, ownerId, ownerId)).toThrow(/yourself/) + // second owner unlocks the demotion of the first + const second = createEmployee(db, ownerId, { email: 'o2@x.co', displayName: 'O2', role: 'owner', password: 'password3' }) + expect(updateEmployee(db, second.id, ownerId, { role: 'manager' }).role).toBe('manager') + }) + + it('deactivate/reactivate round-trips and login respects active', () => { + const { db, ownerId } = withOwner() + const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) + deactivateEmployee(db, ownerId, emp.id) + expect(login(db, 's@x.co', 'password2')).toBeNull() + expect(getEmployee(db, emp.id)!.active).toBe(false) + reactivateEmployee(db, ownerId, emp.id) + expect(login(db, 's@x.co', 'password2')).not.toBeNull() + }) + + it('password reset works and is audited without the hash', () => { + const { db, ownerId } = withOwner() + const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) + setEmployeePassword(db, ownerId, emp.id, 'new-secret-9') + expect(login(db, 's@x.co', 'password2')).toBeNull() + expect(login(db, 's@x.co', 'new-secret-9')).not.toBeNull() + const audit = listAudit(db).find((a) => a.action === 'reset_password' && a.entity_id === emp.id) + expect(audit).toBeDefined() + expect(audit!.before_json ?? '').not.toMatch(/hash/) + expect(audit!.after_json ?? '').not.toMatch(/hash/) + }) + + it('every employee mutation writes an audit row', () => { + const { db, ownerId } = withOwner() + const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) + updateEmployee(db, ownerId, emp.id, { displayName: 'Renamed' }) + deactivateEmployee(db, ownerId, emp.id) + reactivateEmployee(db, ownerId, emp.id) + const actions = listAudit(db).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).map((a) => a.action) + expect(actions).toEqual(expect.arrayContaining(['create', 'update', 'deactivate', 'reactivate'])) + }) + + it('fresh DBs accept manager directly (SCHEMA born correct)', () => { + const { db, ownerId } = withOwner() + const m = createEmployee(db, ownerId, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password1' }) + expect(getEmployee(db, m.id)!.role).toBe('manager') + }) + + it('rebuild migrates an old-CHECK DB preserving rows, and is idempotent', () => { + // Simulate a DB created before the employee slice: old two-role CHECK + one row. + const raw = new Database(':memory:') as DB + raw.exec(`CREATE TABLE staff_user ( + id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner','staff')), + pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 + )`) + raw.prepare( + `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, + ).run('u1', 'a@b.c', 'A', 'owner', 's', 'h') + // Old CHECK rejects manager + expect(() => raw.prepare( + `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, + ).run('u2', 'm@b.c', 'M', 'manager', 's', 'h')).toThrow() + rebuildStaffUserRoleCheck(raw) + // Data preserved, manager now accepted + const kept = raw.prepare(`SELECT id, email, role FROM staff_user WHERE id='u1'`).get() as { id: string } + expect(kept).toMatchObject({ id: 'u1', email: 'a@b.c', role: 'owner' }) + raw.prepare( + `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, + ).run('u2', 'm@b.c', 'M', 'manager', 's', 'h') + // Idempotent: second run is a no-op and loses nothing + rebuildStaffUserRoleCheck(raw) + const n = raw.prepare(`SELECT COUNT(*) AS n FROM staff_user`).get() as { n: number } + expect(n.n).toBe(2) + }) +})