feat(clients): account-owner plumbing — client.owner_id, audited owner route, role gate
Phase 3 of the quote-to-close funnel spec: client.owner_id added additively (SCHEMA + PRAGMA-guarded migrate) so a bare lead is routable to an employee; setClientOwner repo fn (validates active employee, audits set_owner in-txn); PATCH /clients/:id/owner gated inline to owner/manager; shared ownerScope role gate (staff forced to self, widening ignored) for later phases; ClientDetail gains an owner/manager-only Account owner dropdown fed by GET /employees. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
ffba5c3d3e
commit
efdb34e710
@ -0,0 +1,170 @@
|
||||
// apps/hq/test/client-owner.test.ts — Phase 3: owner plumbing.
|
||||
// client.owner_id (additive column), setClientOwner (audited), the ownerScope
|
||||
// role gate (staff forced to self), and PATCH /clients/:id/owner (owner/manager only).
|
||||
import Database from 'better-sqlite3'
|
||||
import express from 'express'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { createStaff, ownerScope } from '../src/auth'
|
||||
import { createEmployee, deactivateEmployee } from '../src/repos-employees'
|
||||
import { createClient, getClient, listClients, setClientOwner } from '../src/repos-clients'
|
||||
import { listAudit } from '../src/audit'
|
||||
import { apiRouter } from '../src/api'
|
||||
|
||||
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('client.owner_id column', () => {
|
||||
it('exists on a fresh DB (SCHEMA born correct) and defaults to NULL', () => {
|
||||
const db = openDb(':memory:')
|
||||
const cols = (db.prepare(`PRAGMA table_info(client)`).all() as { name: string }[]).map((c) => c.name)
|
||||
expect(cols).toContain('owner_id')
|
||||
const c = createClient(db, 'u1', { name: 'Fresh Co', stateCode: '32' })
|
||||
expect(getClient(db, c.id)!.ownerId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('migrate() adds owner_id to a pre-Phase-3 DB, preserving rows, idempotently', () => {
|
||||
// Simulate a DB created before this phase: client table without owner_id + one row.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-owner-'))
|
||||
const raw = new Database(path.join(dir, 'hq.db'))
|
||||
raw.exec(`CREATE TABLE client (
|
||||
id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||
gstin TEXT, state_code TEXT NOT NULL DEFAULT '32', address TEXT NOT NULL DEFAULT '',
|
||||
contacts TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')),
|
||||
notes TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'hq', created_at TEXT NOT NULL
|
||||
)`)
|
||||
raw.prepare(
|
||||
`INSERT INTO client (id, code, name, created_at) VALUES ('c1', 'CL0001', 'Old Row', '2026-01-01T00:00:00Z')`,
|
||||
).run()
|
||||
raw.close()
|
||||
const db = openDb(dir) // exec SCHEMA (IF NOT EXISTS skips client) + migrate() adds the column
|
||||
const cols = (db.prepare(`PRAGMA table_info(client)`).all() as { name: string }[]).map((c) => c.name)
|
||||
expect(cols).toContain('owner_id')
|
||||
const kept = db.prepare(`SELECT id, name, owner_id FROM client WHERE id='c1'`).get()
|
||||
expect(kept).toMatchObject({ id: 'c1', name: 'Old Row', owner_id: null })
|
||||
db.close()
|
||||
const again = openDb(dir) // idempotent re-run loses nothing
|
||||
expect(again.prepare(`SELECT COUNT(*) AS n FROM client`).get()).toMatchObject({ n: 1 })
|
||||
again.close()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('setClientOwner', () => {
|
||||
it('gives a lead a routable owner and writes an audit row in the same transaction', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
|
||||
const lead = createClient(db, ownerId, { name: 'Nagari Sah.', stateCode: '32', status: 'lead' })
|
||||
const after = setClientOwner(db, ownerId, lead.id, emp.id)
|
||||
expect(after.ownerId).toBe(emp.id)
|
||||
expect(getClient(db, lead.id)!.ownerId).toBe(emp.id)
|
||||
expect(listClients(db).find((c) => c.id === lead.id)!.ownerId).toBe(emp.id)
|
||||
const audit = listAudit(db).find((a) => a.action === 'set_owner' && a.entity === 'client' && a.entity_id === lead.id)
|
||||
expect(audit).toBeDefined()
|
||||
expect(JSON.parse(audit!.before_json!)).not.toHaveProperty('ownerId')
|
||||
expect(JSON.parse(audit!.after_json!)).toMatchObject({ ownerId: emp.id })
|
||||
})
|
||||
|
||||
it('clears the owner with null, audited', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
|
||||
const c = createClient(db, ownerId, { name: 'Clearable', stateCode: '32' })
|
||||
setClientOwner(db, ownerId, c.id, emp.id)
|
||||
const cleared = setClientOwner(db, ownerId, c.id, null)
|
||||
expect(cleared.ownerId).toBeUndefined()
|
||||
const audits = listAudit(db).filter((a) => a.action === 'set_owner' && a.entity_id === c.id)
|
||||
expect(audits).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects an unknown employee and an inactive employee; unknown client 404s', () => {
|
||||
const { db, ownerId } = withOwner()
|
||||
const c = createClient(db, ownerId, { name: 'Guarded', stateCode: '32' })
|
||||
expect(() => setClientOwner(db, ownerId, c.id, 'nope')).toThrow(/employee/i)
|
||||
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
|
||||
deactivateEmployee(db, ownerId, emp.id)
|
||||
expect(() => setClientOwner(db, ownerId, c.id, emp.id)).toThrow(/inactive/i)
|
||||
expect(() => setClientOwner(db, ownerId, 'missing', null)).toThrow(/client/i)
|
||||
// failed sets leave no owner and no audit row behind
|
||||
expect(getClient(db, c.id)!.ownerId).toBeUndefined()
|
||||
expect(listAudit(db).filter((a) => a.action === 'set_owner')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ownerScope role gate', () => {
|
||||
it('forces staff to self and ignores any widening param', () => {
|
||||
expect(ownerScope({ id: 'me', role: 'staff' })).toBe('me')
|
||||
expect(ownerScope({ id: 'me', role: 'staff' }, 'someone-else')).toBe('me')
|
||||
})
|
||||
it('owner and manager are unfiltered by default and may narrow', () => {
|
||||
expect(ownerScope({ id: 'o1', role: 'owner' })).toBeUndefined()
|
||||
expect(ownerScope({ id: 'm1', role: 'manager' })).toBeUndefined()
|
||||
expect(ownerScope({ id: 'o1', role: 'owner' }, 'e9')).toBe('e9')
|
||||
expect(ownerScope({ id: 'm1', role: 'manager' }, 'e9')).toBe('e9')
|
||||
expect(ownerScope({ id: 'm1', role: 'manager' }, '')).toBeUndefined() // empty ≠ a filter
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /clients/:id/owner', () => {
|
||||
const db = openDb(':memory:')
|
||||
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
||||
createStaff(db, { email: 'manager@test.in', displayName: 'Mgr', role: 'manager', password: 'manager-password' })
|
||||
createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
|
||||
const staffDbId = (db.prepare(`SELECT id FROM staff_user WHERE email='staff@test.in'`).get() as { id: string }).id
|
||||
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
|
||||
const server = app.listen(0)
|
||||
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
afterAll(() => server.close())
|
||||
|
||||
const tokenOf = async (email: string, password: string) =>
|
||||
(await (await fetch(`${base}/auth/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})).json() as { token: string }).token
|
||||
|
||||
const patchOwner = async (token: string, clientId: string, body: unknown) => {
|
||||
const res = await fetch(`${base}/clients/${clientId}/owner`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return { status: res.status, json: await res.json() as any }
|
||||
}
|
||||
|
||||
it('owner and manager can set an owner; the write is audited; staff gets 403', async () => {
|
||||
const owner = await tokenOf('owner@test.in', 'owner-password')
|
||||
const c = createClient(db, 'seed', { name: 'Routable Lead', stateCode: '32', status: 'lead' })
|
||||
|
||||
const set = await patchOwner(owner, c.id, { ownerId: staffDbId })
|
||||
expect(set.status).toBe(200)
|
||||
expect(set.json.client.ownerId).toBe(staffDbId)
|
||||
const audit = listAudit(db).find((a) => a.action === 'set_owner' && a.entity_id === c.id)
|
||||
expect(audit).toBeDefined()
|
||||
|
||||
const manager = await tokenOf('manager@test.in', 'manager-password')
|
||||
const cleared = await patchOwner(manager, c.id, { ownerId: null })
|
||||
expect(cleared.status).toBe(200)
|
||||
expect(cleared.json.client.ownerId).toBeUndefined()
|
||||
|
||||
const staff = await tokenOf('staff@test.in', 'staff-password')
|
||||
const denied = await patchOwner(staff, c.id, { ownerId: staffDbId })
|
||||
expect(denied.status).toBe(403)
|
||||
expect(getClient(db, c.id)!.ownerId).toBeUndefined() // staff write did not land
|
||||
})
|
||||
|
||||
it('404s an unknown client; 400s a bad employee id or missing ownerId field', async () => {
|
||||
const owner = await tokenOf('owner@test.in', 'owner-password')
|
||||
expect((await patchOwner(owner, 'missing', { ownerId: staffDbId })).status).toBe(404)
|
||||
const c = createClient(db, 'seed', { name: 'Bad Input Co', stateCode: '32' })
|
||||
expect((await patchOwner(owner, c.id, { ownerId: 'not-an-employee' })).status).toBe(400)
|
||||
expect((await patchOwner(owner, c.id, {})).status).toBe(400)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue