diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 496d4bd..4be5b96 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -27,6 +27,10 @@ import { amcPaidStatus, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, listAmc, updateAmc, type AmcPatch, type CreateAmcInput, } from './repos-amc' +import { + createInteraction, createInteractionType, getInteraction, listInteractions, + listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch, +} from './repos-interactions' import { documentHtml } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' @@ -372,6 +376,45 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { } }) + // ---------- interactions ---------- + r.get('/interaction-types', requireAuth, (_req, res) => { + res.json({ ok: true, types: listInteractionTypes(db) }) + }) + r.post('/interaction-types', requireAuth, requireOwner, (req, res) => { + try { + const type = createInteractionType(db, staffId(res), req.body as { code: string; label: string }) + res.json({ ok: true, type }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/clients/:id/interactions', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + res.json({ ok: true, interactions: listInteractions(db, id) }) + }) + r.post('/clients/:id/interactions', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + try { + const interaction = createInteraction(db, staffId(res), { + ...(req.body as Omit), clientId: id, + }) + res.json({ ok: true, interaction }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.patch('/interactions/:id', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getInteraction(db, id) === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return } + try { + res.json({ ok: true, interaction: updateInteraction(db, staffId(res), id, req.body as InteractionPatch) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- payments ---------- r.post('/payments', requireAuth, (req, res) => { try { diff --git a/apps/hq/src/repos-interactions.ts b/apps/hq/src/repos-interactions.ts new file mode 100644 index 0000000..db643f7 --- /dev/null +++ b/apps/hq/src/repos-interactions.ts @@ -0,0 +1,107 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' +import { getClient } from './repos-clients' + +/** Interaction log — the "memories into the system" table (D12 plain-repo pattern). */ + +export interface InteractionType { code: string; label: string } + +export function listInteractionTypes(db: DB): InteractionType[] { + return db.prepare(`SELECT code, label FROM interaction_type ORDER BY label`).all() as InteractionType[] +} + +export function createInteractionType(db: DB, userId: string, input: { code: string; label: string }): InteractionType { + if (input.code.trim() === '' || input.label.trim() === '') throw new Error('code and label are required') + db.prepare(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`).run(input.code.trim(), input.label.trim()) + writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input) + return { code: input.code.trim(), label: input.label.trim() } +} + +export type Outcome = 'positive' | 'neutral' | 'negative' + +export interface Interaction { + id: string; clientId: string; typeCode: string; onDate: string; staffId: string + notes: string; outcome: Outcome | null; followUpOn: string | null; createdAt: string +} + +interface InteractionRow { + id: string; client_id: string; type_code: string; on_date: string; staff_id: string + notes: string; outcome: string | null; follow_up_on: string | null; created_at: string +} + +function toInteraction(r: InteractionRow): Interaction { + return { + id: r.id, clientId: r.client_id, typeCode: r.type_code, onDate: r.on_date, staffId: r.staff_id, + notes: r.notes, outcome: r.outcome as Outcome | null, followUpOn: r.follow_up_on, createdAt: r.created_at, + } +} + +export function getInteraction(db: DB, id: string): Interaction | null { + const row = db.prepare(`SELECT * FROM interaction WHERE id=?`).get(id) as InteractionRow | undefined + return row === undefined ? null : toInteraction(row) +} + +export function listInteractions(db: DB, clientId: string): Interaction[] { + const rows = db.prepare( + `SELECT * FROM interaction WHERE client_id=? ORDER BY on_date DESC, id DESC`, + ).all(clientId) as InteractionRow[] + return rows.map(toInteraction) +} + +export interface CreateInteractionInput { + clientId: string; typeCode: string; onDate: string + notes?: string; outcome?: Outcome; followUpOn?: string | null +} + +const OUTCOMES: Outcome[] = ['positive', 'neutral', 'negative'] + +export function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Interaction { + if (getClient(db, input.clientId) === null) throw new Error('Client not found') + const type = db.prepare(`SELECT code FROM interaction_type WHERE code=?`).get(input.typeCode) + if (type === undefined) throw new Error(`Unknown interaction type: ${input.typeCode}`) + if (input.outcome !== undefined && !OUTCOMES.includes(input.outcome)) throw new Error(`Unknown outcome: ${input.outcome}`) + const id = uuidv7() + db.prepare( + `INSERT INTO interaction (id, client_id, type_code, on_date, staff_id, notes, outcome, follow_up_on, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, input.clientId, input.typeCode, input.onDate, userId, input.notes ?? '', + input.outcome ?? null, input.followUpOn ?? null, new Date().toISOString(), + ) + const interaction = getInteraction(db, id)! + writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction) + return interaction +} + +export interface InteractionPatch { notes?: string; outcome?: Outcome | null; followUpOn?: string | null } + +export function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Interaction { + const before = getInteraction(db, id) + if (before === null) throw new Error('Interaction not found') + if (patch.outcome !== undefined && patch.outcome !== null && !OUTCOMES.includes(patch.outcome)) { + throw new Error(`Unknown outcome: ${patch.outcome}`) + } + const sets: string[] = [] + const args: unknown[] = [] + if (patch.notes !== undefined) { sets.push('notes=?'); args.push(patch.notes) } + if (patch.outcome !== undefined) { sets.push('outcome=?'); args.push(patch.outcome) } + if (patch.followUpOn !== undefined) { sets.push('follow_up_on=?'); args.push(patch.followUpOn) } + if (sets.length > 0) { + args.push(id) + db.prepare(`UPDATE interaction SET ${sets.join(', ')} WHERE id=?`).run(...args) + } + const after = getInteraction(db, id)! + writeAudit(db, userId, 'update', 'interaction', id, before, after) + return after +} + +/** Interactions with a follow-up date on or before the given date — the follow-ups queue. */ +export function listOpenFollowUps(db: DB, onOrBefore: string): (Interaction & { clientName: string })[] { + const rows = db.prepare( + `SELECT i.*, c.name AS client_name FROM interaction i JOIN client c ON c.id = i.client_id + WHERE i.follow_up_on IS NOT NULL AND i.follow_up_on <= ? + ORDER BY i.follow_up_on DESC, i.id DESC`, + ).all(onOrBefore) as (InteractionRow & { client_name: string })[] + return rows.map((r) => ({ ...toInteraction(r), clientName: r.client_name })) +} diff --git a/apps/hq/test/interactions.test.ts b/apps/hq/test/interactions.test.ts new file mode 100644 index 0000000..cd08923 --- /dev/null +++ b/apps/hq/test/interactions.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { + createInteraction, listInteractions, listInteractionTypes, listOpenFollowUps, updateInteraction, +} from '../src/repos-interactions' + +describe('interactions', () => { + it('logs a typed interaction, updates outcome, and surfaces due follow-ups', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + expect(listInteractionTypes(db).length).toBeGreaterThanOrEqual(7) + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const i = createInteraction(db, 'u1', { + clientId: c.id, typeCode: 'site_visit', onDate: '2026-07-01', + notes: 'Installed POS; owner wants training', followUpOn: '2026-07-08', + }) + expect(listInteractions(db, c.id)).toHaveLength(1) + const up = updateInteraction(db, 'u1', i.id, { outcome: 'positive' }) + expect(up.outcome).toBe('positive') + expect(listOpenFollowUps(db, '2026-07-10').map((f) => f.id)).toContain(i.id) + expect(listOpenFollowUps(db, '2026-07-05')).toHaveLength(0) // not yet due + }) + it('rejects an unknown interaction type', () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const c = createClient(db, 'u1', { name: 'X', stateCode: '32' }) + expect(() => createInteraction(db, 'u1', { clientId: c.id, typeCode: 'telepathy', onDate: '2026-07-01' })) + .toThrow(/type/i) + }) +})