feat(hq): interaction log with types, follow-ups and routes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
0e66bd8ff8
commit
868fe17a5d
@ -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 }))
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue