feat(hq): employee foundation — manager role, employee repo, session hardening (phase 1)

- 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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 02306b1088
commit 1af7b87aab

@ -0,0 +1 @@
e0f352cd87dfab994f7622c297edfe2cb1118222bec860b092fa39c362e84090

@ -0,0 +1,94 @@
<h2>“Whos using module X?” — the answer screen</h2>
<p class="subtitle">Pick a module on the left → instantly see every client on it. Two things you asked for in one place: a <b>notify list</b> (support/impact) and <b>what it earns + when it renews</b> (revenue). It lives inside the Modules page you already have — nothing new to learn.</p>
<div class="mockup">
<div class="mockup-header">Modules → RTGS / NEFT roster</div>
<div class="mockup-body">
<div style="display:flex;gap:12px">
<!-- module list -->
<div style="width:150px;flex-shrink:0">
<div style="font-size:10px;text-transform:uppercase;letter-spacing:.5px;opacity:.6;margin-bottom:6px">Modules</div>
<div style="font-size:13px;padding:6px 8px;border-radius:6px;opacity:.8">Core Banking <span style="opacity:.5;float:right">128</span></div>
<div style="font-size:13px;padding:6px 8px;border-radius:6px;background:rgba(99,102,241,.18);border:1px solid rgba(99,102,241,.5);font-weight:600">RTGS / NEFT <span style="opacity:.6;float:right">47</span></div>
<div style="font-size:13px;padding:6px 8px;border-radius:6px;opacity:.8">SMS Alerts <span style="opacity:.5;float:right">210</span></div>
<div style="font-size:13px;padding:6px 8px;border-radius:6px;opacity:.8">Mobile App <span style="opacity:.5;float:right">36</span></div>
<div style="font-size:13px;padding:6px 8px;border-radius:6px;opacity:.8">ATM <span style="opacity:.5;float:right">54</span></div>
<div style="font-size:13px;padding:6px 8px;border-radius:6px;opacity:.8">Passbook <span style="opacity:.5;float:right">89</span></div>
</div>
<!-- roster -->
<div style="flex:1;min-width:0">
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:8px">
<div>
<div style="font-size:18px;font-weight:700">RTGS / NEFT</div>
<div style="font-size:12px;opacity:.7">47 clients · ₹3,80,000 / yr · <span style="color:#f5a623;font-weight:600">2 renew in next 60 days</span></div>
</div>
<div style="display:flex;gap:6px">
<span style="font-size:12px;font-weight:600;background:#6366f1;color:#fff;padding:5px 11px;border-radius:6px">✉ Notify all 47</span>
<span style="font-size:12px;border:1px solid rgba(128,128,128,.4);padding:5px 11px;border-radius:6px">⭳ Export CSV</span>
</div>
</div>
<table style="width:100%;border-collapse:collapse;font-size:12px;margin-top:10px">
<thead>
<tr style="text-align:left;opacity:.6;font-size:10px;text-transform:uppercase;letter-spacing:.5px">
<th style="padding:4px 4px">Client</th><th>District</th><th>On since</th><th>Pays</th><th>Next renewal</th>
</tr>
</thead>
<tbody>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Sahakari Bank Pune</td><td>Pune</td><td>Apr 2022</td><td>₹9,000</td>
<td style="color:#f5a623;font-weight:600">12 Aug 2026 · 26d</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Janata Credit Society</td><td>Sangli</td><td>Sep 2020</td><td>₹7,500</td>
<td style="color:#f5a623;font-weight:600">01 Sep 2026 · 46d</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Deccan Mercantile</td><td>Pune</td><td>Jan 2021</td><td>₹12,000</td>
<td>03 Jan 2027</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Kolhapur Urban Coop</td><td>Kolhapur</td><td>Jun 2023</td><td>₹9,000</td>
<td>15 Jun 2027</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Pratibha Nagari</td><td>Satara</td><td>Mar 2024</td><td>₹9,000</td>
<td>20 Mar 2027</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25);opacity:.55">
<td style="padding:6px 4px" colspan="5">…42 more · list paginates (never silently cut)</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<p class="subtitle" style="margin-top:14px">Does this answer it the way you need? Click the emphasis you want:</p>
<div class="options">
<div class="option" data-choice="asis" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>This is exactly it</h3>
<p>Notify list + revenue + renewals together. Build as shown.</p>
</div>
</div>
<div class="option" data-choice="support" onclick="toggleSelect(this)">
<div class="letter">S</div>
<div class="content">
<h3>Support-first</h3>
<p>Make “Notify all / Export” the hero — for pushing a module update or outage notice fast.</p>
</div>
</div>
<div class="option" data-choice="money" onclick="toggleSelect(this)">
<div class="letter"></div>
<div class="content">
<h3>Money-first</h3>
<p>Lead with revenue & upcoming renewals per module (totals up top, renewals sorted first).</p>
</div>
</div>
</div>

@ -0,0 +1,141 @@
<h2>Quotations Pipeline — pick the view that fits how you chase quotes</h2>
<p class="subtitle">Same data, two ways to see “what needs following up today.” This is the screen that gets messed up right now — well build whichever one you pick. Every quote shows its <b>owner</b> (employee) and its <b>next nudge</b>.</p>
<div class="split">
<!-- ============ OPTION A: KANBAN BOARD ============ -->
<div class="mockup">
<div class="mockup-header">A · Pipeline board — the whole funnel at a glance</div>
<div class="mockup-body">
<div style="display:flex;gap:8px;overflow-x:auto">
<div style="flex:1;min-width:120px">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.7;margin-bottom:6px">Enquiry · 2</div>
<div style="border:1px solid rgba(128,128,128,.35);border-radius:8px;padding:8px;margin-bottom:6px">
<div style="font-weight:600;font-size:13px">Kolhapur Urban Coop</div>
<div style="font-size:12px;opacity:.75">Mobile App · quote not sent</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
<span style="font-size:11px;background:rgba(128,128,128,.18);padding:1px 7px;border-radius:20px">Anil</span>
<span style="font-size:11px;color:#8a8f98">3d</span>
</div>
</div>
<div style="border:1px solid rgba(128,128,128,.35);border-radius:8px;padding:8px">
<div style="font-weight:600;font-size:13px">Pratibha Nagari</div>
<div style="font-size:12px;opacity:.75">RTGS/NEFT</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
<span style="font-size:11px;background:rgba(128,128,128,.18);padding:1px 7px;border-radius:20px">Rahul</span>
<span style="font-size:11px;color:#8a8f98">1d</span>
</div>
</div>
</div>
<div style="flex:1;min-width:120px">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.7;margin-bottom:6px">Quoted · Waiting · 3</div>
<div style="border:1px solid rgba(229,72,77,.5);border-radius:8px;padding:8px;margin-bottom:6px">
<div style="font-weight:600;font-size:13px">Sahakari Bank Pune</div>
<div style="font-size:12px;opacity:.75">Core Banking · ₹1,20,000</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
<span style="font-size:11px;background:rgba(128,128,128,.18);padding:1px 7px;border-radius:20px">Priya</span>
<span style="font-size:11px;color:#e5484d;font-weight:600">chase overdue · 9d</span>
</div>
</div>
<div style="border:1px solid rgba(245,166,35,.55);border-radius:8px;padding:8px;margin-bottom:6px">
<div style="font-weight:600;font-size:13px">Janata Credit Society</div>
<div style="font-size:12px;opacity:.75">SMS Alerts · ₹85,000</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
<span style="font-size:11px;background:rgba(128,128,128,.18);padding:1px 7px;border-radius:20px">Rahul</span>
<span style="font-size:11px;color:#f5a623;font-weight:600">nudge in 2d</span>
</div>
</div>
<div style="border:1px solid rgba(229,72,77,.5);border-radius:8px;padding:8px">
<div style="font-weight:600;font-size:13px">Deccan Mercantile</div>
<div style="font-size:12px;opacity:.75">Core Banking · ₹2,40,000</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px">
<span style="font-size:11px;background:rgba(128,128,128,.18);padding:1px 7px;border-radius:20px">Priya</span>
<span style="font-size:11px;color:#e5484d;font-weight:600">final nudge · 15d</span>
</div>
</div>
</div>
<div style="flex:1;min-width:110px">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.7;margin-bottom:6px">Won · 1</div>
<div style="border:1px solid rgba(48,164,108,.55);border-radius:8px;padding:8px">
<div style="font-weight:600;font-size:13px">Shivneri Sahakari</div>
<div style="font-size:12px;opacity:.75">Mobile App · ₹60,000</div>
<div style="margin-top:6px">
<span style="font-size:11px;color:#30a46c;font-weight:600">→ convert to invoice</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============ OPTION B: CHASE-LIST ============ -->
<div class="mockup">
<div class="mockup-header">B · Chase-list — one ranked “do this now” list</div>
<div class="mockup-body">
<div style="display:flex;gap:6px;margin-bottom:8px;font-size:11px">
<span style="background:rgba(128,128,128,.18);padding:3px 9px;border-radius:20px">All quotes</span>
<span style="border:1px solid rgba(128,128,128,.35);padding:3px 9px;border-radius:20px">Mine</span>
<span style="border:1px solid rgba(128,128,128,.35);padding:3px 9px;border-radius:20px">Overdue</span>
<span style="border:1px solid rgba(128,128,128,.35);padding:3px 9px;border-radius:20px">Owner ▾</span>
</div>
<table style="width:100%;border-collapse:collapse;font-size:12px">
<thead>
<tr style="text-align:left;opacity:.6;font-size:10px;text-transform:uppercase;letter-spacing:.5px">
<th style="padding:4px 4px">Client</th><th>Stage</th><th>Amount</th><th>Owner</th><th>Next action</th>
</tr>
</thead>
<tbody>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Deccan Mercantile</td><td>Quoted</td><td>₹2,40,000</td><td>Priya</td>
<td style="color:#e5484d;font-weight:600">Final nudge · 15d</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Sahakari Bank Pune</td><td>Quoted</td><td>₹1,20,000</td><td>Priya</td>
<td style="color:#e5484d;font-weight:600">Chase overdue · 9d</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Janata Credit Society</td><td>Quoted</td><td>₹85,000</td><td>Rahul</td>
<td style="color:#f5a623;font-weight:600">Nudge in 2d</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Kolhapur Urban Coop</td><td>Enquiry</td><td></td><td>Anil</td>
<td style="opacity:.8">Send quote</td>
</tr>
<tr style="border-top:1px solid rgba(128,128,128,.25)">
<td style="padding:6px 4px;font-weight:600">Shivneri Sahakari</td><td>Won</td><td>₹60,000</td><td>Rahul</td>
<td style="color:#30a46c;font-weight:600">Convert to invoice</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Pipeline board</h3>
<p>See every quote by stage, drag a card as it moves Enquiry → Quoted → Won. Best for seeing the whole funnels shape.</p>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Chase-list</h3>
<p>One ranked list — most overdue on top, colour-coded, “next action” spelled out. Less clicking, more doing. Fewer things to mess up.</p>
</div>
</div>
<div class="option" data-choice="both" onclick="toggleSelect(this)">
<div class="letter">A+B</div>
<div class="content">
<h3>Board + a “needs chasing” strip</h3>
<p>The board as the main view, with the overdue chase-list pinned at the top as a to-do strip. Slightly more to build.</p>
</div>
</div>
</div>

@ -0,0 +1,8 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;text-align:center">
<div>
<div style="font-size:44px;margin-bottom:10px">🐝</div>
<h2>Design swarm is running…</h2>
<p class="subtitle">9 agents: 5 designing each piece in parallel → 3 stress-testing → 1 synthesizing the spec + roadmap.<br>Back in the terminal in a few minutes with the full design for your approval.</p>
<p class="subtitle" style="opacity:.6;margin-top:18px">Locked: employee foundation · chase-list pipeline · module roster · escalating quote reminders · one-click proforma→invoice</p>
</div>
</div>

@ -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}

@ -0,0 +1 @@
65410f13a14f7a21cd6975494bda8c56b77ef1fdd0b00415

@ -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
}

@ -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'],
)
}

@ -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
})()
}

@ -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)
})
})
Loading…
Cancel
Save