You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/plans/2026-07-10-hq2-reminders.md

969 lines
52 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# HQ-2 — Reminders, Recurring, AMC, Interactions & Dashboard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task is a full TDD loop: write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → commit. No task depends on code a later task writes.
**Goal:** Ship HQ-2 of [docs/14-SPEC-HQ-CONSOLE.md](../../14-SPEC-HQ-CONSOLE.md) on top of the HQ-1 `apps/hq` + `apps/hq-web` codebase: recurring plans that generate invoices on schedule, a reminder engine (auto-send + manual queue, idempotent across crashes/catch-up, bounce-aware), AMC contracts with derived paid-state, an interaction/visit/call/training log with follow-ups, and a dashboard money-view that becomes the default page after login. All grounded in the real HQ-1 interfaces (`createDraft`/`issueDocument`, `sendDocumentEmail`, `getAccount`/`TokenDeadError`, `priceOn`, `writeAudit`, `formatINR`).
**Architecture:** Extend the store-server-shaped HQ pattern exactly as HQ-1 established it — express + better-sqlite3 behind plain-function repositories (`repos-*.ts`), pure logic reused from `@sims/domain` (`formatINR`, `fromRupees`, `uuidv7`, `fyOf`) and the HQ document engine. **New tables are added to the `SCHEMA` string in `db.ts` via `CREATE TABLE IF NOT EXISTS`** (no ALTER needed — the table simply appears on next `openDb`); **the one new column** (`email_log.bounced`) is added through the existing guarded `migrate(db)` PRAGMA/ALTER path. The scheduler is a **pure function** `runDailyScan(db, deps, today)` with an injected clock (never `Date.now()` inside logic) so tests are deterministic; it is wired into `server.ts` to run on boot and every 6h. Idempotency lives entirely in one UNIQUE constraint — `reminder(rule_kind, subject_id, due_period)` — reached via `INSERT OR IGNORE`, which is what makes catch-up after downtime safe with zero extra bookkeeping.
**Tech Stack:** unchanged from HQ-1 — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (PDF, injected into scheduler/reminder deps so tests never launch Chromium), vitest, React 19 + Vite 6 + react-router 7, `@sims/ui`. No new dependencies (bounce polling is `fetch`-based, same as `gmail.ts`).
## Global Constraints
Everything from the HQ-1 plan still holds; the load-bearing ones for HQ-2, plus the extensions:
- All money is **integer paise** (`Paise` from `@sims/domain`); never floats. Amounts render with `formatINR`.
- All ids are **UUIDv7** via `uuidv7()` from `@sims/domain` (ids sort by creation time — reused for `ORDER BY id`).
- **Every mutation writes an `audit_log` row** via `writeAudit(db, userId, action, entity, entityId, before?, after?)`. Scheduler-originated mutations use `userId = 'system'`.
- SQL stays **portable** (D12 guardrail): standard SQL; SQLite/Postgres-only dialect (`INSERT OR IGNORE`, `ON CONFLICT`) is commented as a portability quirk, matching `series.ts`/`seed.ts`.
- **New tables → `SCHEMA` const in `db.ts` with `CREATE TABLE IF NOT EXISTS`.** New tables need no migration entry. **New columns → guarded `migrate(db)`** (PRAGMA `table_info` check then `ALTER TABLE … ADD COLUMN`), exactly like the existing `module.quote_content` block.
- HQ prices are **GST-exclusive** (`priceIncludesTax: false`); recurring/AMC invoices flow through the same `createDraft``computeBill` path, so GST (CGST/SGST vs IGST) is computed identically to HQ-1.
- Issued invoices are **never edited or deleted**. A recurring invoice, once generated and issued, exists permanently; a failed *send* never deletes or regenerates it.
- **Scheduler determinism:** `runDailyScan(db, deps, today: string)` takes the business date as an ISO `YYYY-MM-DD` param. Wall-clock timestamps (`created_at`, `sent_at`) come from `deps.now?.() ?? new Date().toISOString()` so tests pin them.
- **Idempotency / at-most-once:** the reminder row is created once per `(rule_kind, subject_id, due_period)` via `INSERT OR IGNORE`. Recurring generation, `next_run` advance, and the reminder insert are one better-sqlite3 **transaction** (atomic); the async email send happens *after* commit and only ever transitions an already-committed reminder from `queued`→`sent`/`failed`. A crash therefore never double-generates and never auto-double-sends.
- **Failed send never advances the schedule:** for recurring, `next_run` advances on *generation* (the invoice existing is the spec's condition), not on send — a failed send parks the reminder in the manual queue with its `error` visible; the invoice still exists and is re-sent from the queue, never regenerated (the UNIQUE key forbids regeneration).
- **"sent" ≠ "delivered":** `email_log.status='sent'` means Gmail accepted the message. Delivery failures surface later via `pollBounces``email_log.bounced=1` + an `email_bounced` dashboard reminder.
- Server port **5182**. Secrets: `HQ_SECRET_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (already used by HQ-1). Never log tokens.
- Tests: vitest, colocated under `apps/hq/test/` (already in the vitest `include`). Puppeteer is injected as `deps.renderPdf`, and `fetch` as `deps.gmail.f`, so every HQ-2 test runs offline and Chromium-free.
- **Per-app typecheck is part of the root gate:** `npm run typecheck` runs `tsc -p tsconfig.json && npm run typecheck --workspaces --if-present`. Both `apps/hq` and `apps/hq-web` must typecheck clean at every commit.
- Commit after every task. Each commit message ends with the trailer — use the two-`-m` form:
```bash
git commit -m "feat(hq): <subject>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
## Scope (locked — do not reopen)
**In:** recurring_plan (generation on `next_run`), reminder engine (auto + manual queue, idempotent, bounce-aware), amc_contract (+ renewal invoice, derived paid), interaction + interaction_type (+ follow-ups), dashboard money-view (default route) with the manual reminder queue, bounce detection (Gmail `readonly` scope + `pollBounces`), and the routes/pages for all of it.
**Out (do not plan):** SMS pack-balance tracking (founder decision pending); AWS cost pull + cross-client chart (HQ-3); receipt PDFs. Per-rule auto/manual toggles for the *dunning* rules (overdue/renewal/amc) are intentionally not built — the only **auto** path is `recurring_plan.policy='auto'`; dunning/renewal/amc/follow-up reminders always land in the **manual queue** (this satisfies the founder's "both modes exist": auto = recurring, manual = everything else, and recurring plans carry their own `auto`/`manual` policy). State this in the recurring task, don't re-litigate it.
## File Structure (HQ-2 additions)
```
apps/hq/
src/db.ts — MODIFY: 5 new CREATE TABLE IF NOT EXISTS + email_log.bounced in migrate()
src/seed.ts — MODIFY: seed interaction_type rows, AMC module, reminder.* settings
src/repos-recurring.ts — NEW: recurring_plan CRUD
src/repos-amc.ts — NEW: amc_contract CRUD + renewal invoice + derived paid
src/repos-interactions.ts — NEW: interaction_type + interaction CRUD + follow-ups
src/reminder-templates.ts — NEW: subject+body text per rule_kind (formatINR, Indian business English)
src/repos-reminders.ts — NEW: reminder upsert/list/dismiss + sendReminder orchestration + settings helpers
src/gmail.ts — MODIFY: add sendReminderEmail (attachment-optional, no document coupling)
src/scheduler.ts — NEW: runDailyScan (pure), startScheduler (setInterval, unref)
src/bounces.ts — NEW: pollBounces + markBounce (fetch-based, injectable Fetcher)
src/repos-payments.ts — MODIFY: export outstandingPaise(db, docId)
src/repos-dashboard.ts — NEW: dashboardView aggregation
src/api.ts — MODIFY: recurring / amc / interactions / reminders / dashboard routes
src/server.ts — MODIFY: startServer(port, { scheduler }) wiring
scripts/gmail-connect.ts — MODIFY: SCOPE gains gmail.readonly (for bounce polling)
test/*.test.ts — NEW: one file per task
apps/hq-web/
src/api.ts — MODIFY: types + typed calls for recurring/amc/interactions/reminders/dashboard
src/Layout.tsx — MODIFY: nav gains Dashboard (default) + Clients
src/main.tsx — MODIFY: '/' → Dashboard, '/clients' → Clients
src/pages/Dashboard.tsx — NEW: money view + manual reminder queue
src/pages/ClientDetail.tsx — MODIFY: AMC, Interactions, Recurring sections
```
---
### Task 1: HQ-2 schema — new tables, `email_log.bounced`, seeds
**Files:**
- Modify: `apps/hq/src/db.ts` (append 5 tables to `SCHEMA`; add the `bounced` column to `migrate`), `apps/hq/src/seed.ts` (seed lookups + settings + AMC module)
- Test: `apps/hq/test/hq2-schema.test.ts`
**Interfaces:**
- Produces (schema only — no new TS exports): tables `recurring_plan`, `amc_contract`, `interaction`, `interaction_type`, `reminder`; column `email_log.bounced INTEGER NOT NULL DEFAULT 0`. `seedIfEmpty` additionally seeds the 7 `interaction_type` rows, one `module` row `code='AMC'`, and settings `reminders.overdue_days='7'`, `reminders.renewal_days='15'`.
- Note on `reminder.doc_id`: the locked column list is extended with **one** nullable column, `doc_id TEXT`, carrying the document a reminder concerns (the overdue invoice, the generated recurring invoice, an AMC renewal invoice). It is required so the manual-queue **Send** action and the recurring auto-send can attach/reference the right PDF; `subject_id` stays the logical subject (plan/module/contract/interaction/invoice). For `invoice_overdue`, `subject_id == doc_id`.
- [ ] **Step 1: Write the failing test**
```ts
// apps/hq/test/hq2-schema.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
describe('hq2 schema', () => {
it('creates every HQ-2 table', () => {
const db = openDb(':memory:')
const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map((r) => r.name)
for (const t of ['recurring_plan', 'amc_contract', 'interaction', 'interaction_type', 'reminder'])
expect(names, `missing table ${t}`).toContain(t)
})
it('adds the bounced column to email_log', () => {
const db = openDb(':memory:')
const cols = (db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]).map((c) => c.name)
expect(cols).toContain('bounced')
})
it('enforces the reminder idempotency key', () => {
const db = openDb(':memory:')
const ins = db.prepare(
`INSERT OR IGNORE INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES (?, 'invoice_overdue', 's1', '2026-07', 'c1', 'queued', 'manual', '2026-07-10T00:00:00Z')`,
)
expect(ins.run('r1').changes).toBe(1)
expect(ins.run('r2').changes).toBe(0) // same (rule_kind, subject_id, due_period) → ignored
})
it('seeds interaction types, the AMC module and reminder settings', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const types = (db.prepare(`SELECT code FROM interaction_type`).all() as { code: string }[]).map((r) => r.code)
expect(types).toEqual(expect.arrayContaining(['call', 'site_visit', 'training', 'complaint']))
expect(db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get()).toMatchObject({ n: 1 })
const s = (db.prepare(`SELECT value FROM setting WHERE key='reminders.overdue_days'`).get() as { value: string } | undefined)
expect(s?.value).toBe('7')
})
})
```
- [ ] **Step 2: Run to verify FAIL**`npx vitest run apps/hq/test/hq2-schema.test.ts` → FAIL (tables/column/seeds absent).
- [ ] **Step 3: Implement.** Append to the `SCHEMA` template string in `apps/hq/src/db.ts` (before the closing backtick):
```sql
CREATE TABLE IF NOT EXISTS recurring_plan (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL,
client_module_id TEXT, -- source of the invoice line's module; required for generation in HQ-2
cadence TEXT NOT NULL CHECK (cadence IN ('monthly','yearly')),
amount_paise INTEGER, -- NULL = price from module_price_book at generation time
next_run TEXT NOT NULL,
policy TEXT NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')),
active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS amc_contract (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, coverage TEXT NOT NULL DEFAULT '',
period_from TEXT NOT NULL, period_to TEXT NOT NULL, amount_paise INTEGER NOT NULL,
renewal_reminder_days INTEGER NOT NULL DEFAULT 30,
legacy_paid INTEGER, -- manual flag for imported contracts only; else paid derives from invoice_doc_id
invoice_doc_id TEXT, active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS interaction_type (
code TEXT PRIMARY KEY, label TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS interaction (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, type_code TEXT NOT NULL,
on_date TEXT NOT NULL, staff_id TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '',
outcome TEXT CHECK (outcome IN ('positive','neutral','negative')),
follow_up_on TEXT, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reminder (
id TEXT PRIMARY KEY,
rule_kind TEXT NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced')),
subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice); NULL otherwise
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error TEXT, created_at TEXT NOT NULL, sent_at TEXT,
UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period
);
```
In `migrate(db)` add the guarded column (same shape as the existing `quote_content` block):
```ts
const emailCols = db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]
if (!emailCols.some((c) => c.name === 'bounced')) {
db.exec(`ALTER TABLE email_log ADD COLUMN bounced INTEGER NOT NULL DEFAULT 0`)
}
```
In `apps/hq/src/seed.ts` — import `createModule` and extend `seedIfEmpty` (after the existing GST18 block):
```ts
import { createModule } from './repos-modules'
// ...
const INTERACTION_TYPES: [string, string][] = [
['call', 'Call'], ['site_visit', 'Site visit'], ['training', 'Training'],
['courtesy_meeting', 'Courtesy meeting'], ['committee_meeting', 'Committee meeting'],
['demo', 'Demo'], ['complaint', 'Complaint'],
]
const REMINDER_SETTINGS: Record<string, string> = {
'reminders.overdue_days': '7',
'reminders.renewal_days': '15',
}
```
and inside `seedIfEmpty`, after the tax-class seed:
```ts
const typeInsert = db.prepare(
`INSERT INTO interaction_type (code, label) VALUES (?, ?) ON CONFLICT (code) DO NOTHING`,
)
let seededTypes = 0
for (const [code, label] of INTERACTION_TYPES) seededTypes += typeInsert.run(code, label).changes
if (seededTypes > 0) writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes })
let seededReminderSettings = 0
for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes
if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS)
const amc = db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get() as { n: number }
if (amc.n === 0) {
// The AMC/renewal invoice line hangs off a real module so it flows through
// createDraft → computeBill unchanged. SAC 998719 (maintenance/repair) — CA to confirm.
createModule(db, 'system', {
code: 'AMC', name: 'Annual Maintenance Contract', sac: '998719',
allowedKinds: ['yearly', 'one_time'],
})
}
```
(`insert` is the existing `INSERT … ON CONFLICT (key) DO NOTHING` setting statement already defined in `seedIfEmpty`.)
- [ ] **Step 4: Run tests**`npx vitest run apps/hq/test/hq2-schema.test.ts` → PASS. `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/db.ts apps/hq/src/seed.ts apps/hq/test/hq2-schema.test.ts
git commit -m "feat(hq): hq-2 schema — recurring/amc/interaction/reminder tables + bounce column + seeds" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 2: Recurring plans — repo + owner-gated routes
**Files:**
- Create: `apps/hq/src/repos-recurring.ts`
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/recurring.test.ts`
**Interfaces:**
- Produces:
- `RecurringPlan = { id: string; clientId: string; clientModuleId: string | null; cadence: 'monthly'|'yearly'; amountPaise: number | null; nextRun: string; policy: 'auto'|'manual'; active: boolean }`
- `createRecurringPlan(db, userId, input: { clientId: string; clientModuleId: string; cadence: 'monthly'|'yearly'; amountPaise?: number; nextRun: string; policy?: 'auto'|'manual' }): RecurringPlan` — validates the client and client_module exist and belong together; validates that a price is resolvable (either `amountPaise` given, or the client_module's module has a `priceOn` for the cadence's kind on `nextRun`). **`clientModuleId` is required in HQ-2** (it is the module for the invoice line; the schema keeps the column nullable for future client-level plans).
- `getRecurringPlan(db, id): RecurringPlan | null`; `listRecurringPlans(db, clientId?): RecurringPlan[]`.
- `updateRecurringPlan(db, userId, id, patch: { cadence?; amountPaise?: number | null; nextRun?; policy?; active? }): RecurringPlan`.
- `deactivateRecurringPlan(db, userId, id): RecurringPlan` (sets `active=0`).
- `CADENCE_KIND: Record<'monthly'|'yearly', Kind> = { monthly: 'monthly', yearly: 'yearly' }` (exported — reused by the scheduler).
- Routes (create/update/deactivate are **owner-gated** — money): `GET /api/recurring?clientId=`, `POST /api/clients/:id/recurring` (owner), `PATCH /api/recurring/:id` (owner), `POST /api/recurring/:id/deactivate` (owner).
- [ ] **Step 1: Write the failing test**
```ts
// apps/hq/test/recurring.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients'
import { assignModule, createModule, setPrice } from '../src/repos-modules'
import {
createRecurringPlan, listRecurringPlans, updateRecurringPlan, deactivateRecurringPlan,
} from '../src/repos-recurring'
function setup() {
const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud Hosting', allowedKinds: ['monthly', 'yearly'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-04-01' })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
return { db, c, m, cm }
}
describe('recurring plans', () => {
it('creates a plan, lists it, updates and deactivates with audit', () => {
const { db, c, cm } = setup()
const p = createRecurringPlan(db, 'u1', {
clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', policy: 'auto',
})
expect(p.policy).toBe('auto')
expect(p.amountPaise).toBeNull() // price resolves from the module at generation
expect(listRecurringPlans(db, c.id)).toHaveLength(1)
const up = updateRecurringPlan(db, 'u1', p.id, { amountPaise: 2_500_00 })
expect(up.amountPaise).toBe(2_500_00)
const off = deactivateRecurringPlan(db, 'u1', p.id)
expect(off.active).toBe(false)
const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='recurring_plan'`).all()
expect(audits.length).toBe(3) // create + update + deactivate
})
it('rejects a plan whose module has no price and no explicit amount', () => {
const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'X', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
expect(() => createRecurringPlan(db, 'u1', {
clientId: c.id, clientModuleId: cm.id, cadence: 'yearly', nextRun: '2026-08-01',
})).toThrow(/price/i)
})
it('rejects a client_module that belongs to another client', () => {
const { db, cm } = setup()
const other = createClient(db, 'u1', { name: 'Other', stateCode: '32' })
expect(() => createRecurringPlan(db, 'u1', {
clientId: other.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', amountPaise: 100_00,
})).toThrow(/client/i)
})
})
```
- [ ] **Step 2: Run to verify FAIL**`npx vitest run apps/hq/test/recurring.test.ts` → FAIL (module not found).
- [ ] **Step 3: Implement `apps/hq/src/repos-recurring.ts`**
```ts
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import type { DB } from './db'
import { getClient } from './repos-clients'
import { getClientModule, priceOn, type Kind } from './repos-modules'
/** Recurring billing plans — plain functions over the handle (D12 portable-repo pattern). */
export type Cadence = 'monthly' | 'yearly'
export const CADENCE_KIND: Record<Cadence, Kind> = { monthly: 'monthly', yearly: 'yearly' }
export interface RecurringPlan {
id: string; clientId: string; clientModuleId: string | null; cadence: Cadence
amountPaise: number | null; nextRun: string; policy: 'auto' | 'manual'; active: boolean
}
interface RecurringRow {
id: string; client_id: string; client_module_id: string | null; cadence: string
amount_paise: number | null; next_run: string; policy: string; active: number
}
function toPlan(r: RecurringRow): RecurringPlan {
return {
id: r.id, clientId: r.client_id, clientModuleId: r.client_module_id,
cadence: r.cadence as Cadence, amountPaise: r.amount_paise, nextRun: r.next_run,
policy: r.policy as 'auto' | 'manual', active: r.active === 1,
}
}
export function getRecurringPlan(db: DB, id: string): RecurringPlan | null {
const row = db.prepare(`SELECT * FROM recurring_plan WHERE id=?`).get(id) as RecurringRow | undefined
return row === undefined ? null : toPlan(row)
}
export function listRecurringPlans(db: DB, clientId?: string): RecurringPlan[] {
const rows = clientId === undefined
? db.prepare(`SELECT * FROM recurring_plan ORDER BY id DESC`).all() as RecurringRow[]
: db.prepare(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`).all(clientId) as RecurringRow[]
return rows.map(toPlan)
}
export interface CreateRecurringInput {
clientId: string; clientModuleId: string; cadence: Cadence
amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual'
}
export function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): RecurringPlan {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
const cm = getClientModule(db, input.clientModuleId)
if (cm === null) throw new Error('Client module not found')
if (cm.clientId !== input.clientId) throw new Error('Client module belongs to another client')
if (input.amountPaise !== undefined && (!Number.isInteger(input.amountPaise) || input.amountPaise < 0)) {
throw new Error('amountPaise must be a non-negative integer (paise)')
}
// A plan must be priceable at generation time: explicit amount, or a resolvable module price.
if (input.amountPaise === undefined) {
const kind = CADENCE_KIND[input.cadence]
if (priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun) === null) {
throw new Error(`No ${kind} price for the module on ${input.nextRun}; set an amount or add a price`)
}
}
const id = uuidv7()
db.prepare(
`INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
).run(
id, input.clientId, input.clientModuleId, input.cadence,
input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual',
)
const plan = getRecurringPlan(db, id)!
writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan)
return plan
}
export interface RecurringPatch {
cadence?: Cadence; amountPaise?: number | null; nextRun?: string
policy?: 'auto' | 'manual'; active?: boolean
}
export function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): RecurringPlan {
const before = getRecurringPlan(db, id)
if (before === null) throw new Error('Recurring plan not found')
const sets: string[] = []
const args: unknown[] = []
if (patch.cadence !== undefined) { sets.push('cadence=?'); args.push(patch.cadence) }
if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) }
if (patch.nextRun !== undefined) { sets.push('next_run=?'); args.push(patch.nextRun) }
if (patch.policy !== undefined) { sets.push('policy=?'); args.push(patch.policy) }
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) {
args.push(id)
db.prepare(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`).run(...args)
}
const after = getRecurringPlan(db, id)!
writeAudit(db, userId, 'update', 'recurring_plan', id, before, after)
return after
}
export function deactivateRecurringPlan(db: DB, userId: string, id: string): RecurringPlan {
return updateRecurringPlan(db, userId, id, { active: false })
}
```
Wire routes in `apps/hq/src/api.ts` — add the import and the block (owner gate on writes, matching the modules block):
```ts
import {
createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans,
updateRecurringPlan, type CreateRecurringInput, type RecurringPatch,
} from './repos-recurring'
```
```ts
// ---------- recurring plans ----------
r.get('/recurring', requireAuth, (req, res) => {
const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined
res.json({ ok: true, plans: listRecurringPlans(db, clientId) })
})
r.post('/clients/:id/recurring', requireAuth, requireOwner, (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 plan = createRecurringPlan(db, staffId(res), {
...(req.body as Omit<CreateRecurringInput, 'clientId'>), clientId: id,
})
res.json({ ok: true, plan })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.patch('/recurring/:id', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
try {
res.json({ ok: true, plan: updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/recurring/:id/deactivate', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) })
})
```
- [ ] **Step 4: Run tests**`npx vitest run apps/hq/test/recurring.test.ts` → PASS. `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/repos-recurring.ts apps/hq/src/api.ts apps/hq/test/recurring.test.ts
git commit -m "feat(hq): recurring plan repo and owner-gated routes" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 3: AMC contracts — repo, renewal invoice, derived paid state, routes
**Files:**
- Create: `apps/hq/src/repos-amc.ts`
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/amc.test.ts`
**Interfaces:**
- Consumes: `createDraft`, `issueDocument`, `getDocument`; `outstandingPaise` (added in Task 7 — until then AMC paid-state uses the local `outstandingOf` helper defined here that mirrors `repos-payments`; **when Task 7 lands, switch `amcPaidStatus` to import `outstandingPaise`**). To keep Task 3 self-contained, this task defines its own tiny outstanding read; Task 7 does not require editing Task 3.
- Produces:
- `AmcContract = { id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string; amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null; invoiceDocId: string | null; active: boolean }`
- `createAmc(db, userId, input: { clientId; coverage?; periodFrom; periodTo; amountPaise; renewalReminderDays?; legacyPaid?: boolean }): AmcContract`
- `getAmc(db, id): AmcContract | null`; `listAmc(db, clientId): AmcContract[]`
- `updateAmc(db, userId, id, patch): AmcContract`; `deactivateAmc(db, userId, id): AmcContract`
- `generateAmcRenewalInvoice(db, userId, amcId): Doc` — builds one INVOICE line via the seeded `AMC` module (`description = coverage`, `qty 1`, `kind 'yearly'`, `unitPricePaise = amc.amountPaise`), `createDraft``issueDocument`, then sets `amc_contract.invoice_doc_id`; throws if an invoice is already linked and unpaid.
- `amcPaidStatus(db, amc): 'paid' | 'unpaid' | 'unbilled'``legacyPaid === true``'paid'`; else no `invoiceDocId``'unbilled'`; else outstanding on the linked invoice `<= 0``'paid'` else `'unpaid'`.
- Routes: `GET /api/clients/:id/amc`, `POST /api/clients/:id/amc` (owner), `PATCH /api/amc/:id` (owner), `POST /api/amc/:id/deactivate` (owner), `POST /api/amc/:id/renewal-invoice` (owner).
- [ ] **Step 1: Write the failing test**
```ts
// apps/hq/test/amc.test.ts
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { recordPayment } from '../src/repos-payments'
import {
createAmc, listAmc, generateAmcRenewalInvoice, amcPaidStatus, getAmc,
} from '../src/repos-amc'
function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) // seeds company.state_code, GST18, AMC module
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
return { db, c }
}
describe('amc contracts', () => {
it('creates, generates a renewal invoice, and derives paid from settlement', () => {
const { db, c } = setup()
const amc = createAmc(db, 'u1', {
clientId: c.id, coverage: 'On-site support', periodFrom: '2026-04-01', periodTo: '2027-03-31',
amountPaise: 20_000_00,
})
expect(listAmc(db, c.id)).toHaveLength(1)
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unbilled')
const inv = generateAmcRenewalInvoice(db, 'u1', amc.id)
expect(inv.docType).toBe('INVOICE')
expect(inv.payablePaise).toBe(23_600_00) // 20,000 + 18% GST intra-state
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unpaid')
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 23_600_00 })
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid')
})
it('honours the legacy_paid flag for imported contracts without an invoice', () => {
const { db, c } = setup()
const amc = createAmc(db, 'u1', {
clientId: c.id, periodFrom: '2025-04-01', periodTo: '2026-03-31', amountPaise: 10_000_00, legacyPaid: true,
})
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid')
})
})
```
- [ ] **Step 2: Run to verify FAIL**`npx vitest run apps/hq/test/amc.test.ts` → FAIL.
- [ ] **Step 3: Implement `apps/hq/src/repos-amc.ts`**
```ts
import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import type { DB } from './db'
import { getClient } from './repos-clients'
import { getModule } from './repos-modules'
import { createDraft, getDocument, issueDocument, type Doc } from './repos-documents'
/** AMC contracts — plain functions over the handle (D12 pattern). Paid state is
* derived from the linked invoice's settlement; legacy_paid is a manual flag for
* imported contracts only, so there is one source of truth and no drift. */
export interface AmcContract {
id: string; clientId: string; coverage: string; periodFrom: string; periodTo: string
amountPaise: number; renewalReminderDays: number; legacyPaid: boolean | null
invoiceDocId: string | null; active: boolean
}
interface AmcRow {
id: string; client_id: string; coverage: string; period_from: string; period_to: string
amount_paise: number; renewal_reminder_days: number; legacy_paid: number | null
invoice_doc_id: string | null; active: number
}
function toAmc(r: AmcRow): AmcContract {
return {
id: r.id, clientId: r.client_id, coverage: r.coverage,
periodFrom: r.period_from, periodTo: r.period_to, amountPaise: r.amount_paise,
renewalReminderDays: r.renewal_reminder_days,
legacyPaid: r.legacy_paid === null ? null : r.legacy_paid === 1,
invoiceDocId: r.invoice_doc_id, active: r.active === 1,
}
}
export function getAmc(db: DB, id: string): AmcContract | null {
const row = db.prepare(`SELECT * FROM amc_contract WHERE id=?`).get(id) as AmcRow | undefined
return row === undefined ? null : toAmc(row)
}
export function listAmc(db: DB, clientId: string): AmcContract[] {
const rows = db.prepare(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`).all(clientId) as AmcRow[]
return rows.map(toAmc)
}
export interface CreateAmcInput {
clientId: string; coverage?: string; periodFrom: string; periodTo: string
amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean
}
export function createAmc(db: DB, userId: string, input: CreateAmcInput): AmcContract {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) {
throw new Error('amountPaise must be a non-negative integer (paise)')
}
const id = uuidv7()
db.prepare(
`INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise,
renewal_reminder_days, legacy_paid, invoice_doc_id, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`,
).run(
id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise,
input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0,
)
const amc = getAmc(db, id)!
writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc)
return amc
}
export interface AmcPatch {
coverage?: string; periodFrom?: string; periodTo?: string; amountPaise?: number
renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean
}
export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): AmcContract {
const before = getAmc(db, id)
if (before === null) throw new Error('AMC contract not found')
const sets: string[] = []
const args: unknown[] = []
if (patch.coverage !== undefined) { sets.push('coverage=?'); args.push(patch.coverage) }
if (patch.periodFrom !== undefined) { sets.push('period_from=?'); args.push(patch.periodFrom) }
if (patch.periodTo !== undefined) { sets.push('period_to=?'); args.push(patch.periodTo) }
if (patch.amountPaise !== undefined) { sets.push('amount_paise=?'); args.push(patch.amountPaise) }
if (patch.renewalReminderDays !== undefined) { sets.push('renewal_reminder_days=?'); args.push(patch.renewalReminderDays) }
if (patch.legacyPaid !== undefined) { sets.push('legacy_paid=?'); args.push(patch.legacyPaid === null ? null : patch.legacyPaid ? 1 : 0) }
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) {
args.push(id)
db.prepare(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`).run(...args)
}
const after = getAmc(db, id)!
writeAudit(db, userId, 'update', 'amc_contract', id, before, after)
return after
}
export function deactivateAmc(db: DB, userId: string, id: string): AmcContract {
return updateAmc(db, userId, id, { active: false })
}
/** Renewal invoice for an AMC: one line on the seeded AMC module, priced at the
* contract amount, issued and linked back via invoice_doc_id. */
export function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Doc {
const amc = getAmc(db, amcId)
if (amc === null) throw new Error('AMC contract not found')
if (amc.invoiceDocId !== null) {
const existing = getDocument(db, amc.invoiceDocId)
if (existing !== null && existing.status !== 'cancelled' && outstandingOf(db, existing.id) > 0) {
throw new Error(`AMC already has an unpaid renewal invoice ${existing.docNo}`)
}
}
const amcModule = db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string } | undefined
if (amcModule === undefined) throw new Error('AMC module missing — run the seed')
return db.transaction(() => {
const draft = createDraft(db, userId, {
docType: 'INVOICE', clientId: amc.clientId,
lines: [{
moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance',
qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise,
}],
})
const inv = issueDocument(db, userId, draft.id)
db.prepare(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`).run(inv.id, amcId)
writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id })
return inv
})()
}
/** Local outstanding read (payable allocations non-cancelled credit notes) —
* mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can
* switch to it then, but does not depend on Task 7 to compile. */
function outstandingOf(db: DB, docId: string): number {
const doc = getDocument(db, docId)
if (doc === null) return 0
const alloc = (db.prepare(
`SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`,
).get(docId) as { total: number }).total
const credited = (db.prepare(
`SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`,
).get(docId) as { total: number }).total
return doc.payablePaise - alloc - credited
}
export function amcPaidStatus(db: DB, amc: AmcContract): 'paid' | 'unpaid' | 'unbilled' {
if (amc.legacyPaid === true) return 'paid'
if (amc.invoiceDocId === null) return 'unbilled'
return outstandingOf(db, amc.invoiceDocId) <= 0 ? 'paid' : 'unpaid'
}
```
Wire routes in `api.ts` (import + block; writes owner-gated):
```ts
import {
amcPaidStatus, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, listAmc,
updateAmc, type AmcPatch, type CreateAmcInput,
} from './repos-amc'
```
```ts
// ---------- amc ----------
r.get('/clients/:id/amc', 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 }
const contracts = listAmc(db, id).map((a) => ({ ...a, paidStatus: amcPaidStatus(db, a) }))
res.json({ ok: true, contracts })
})
r.post('/clients/:id/amc', requireAuth, requireOwner, (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 amc = createAmc(db, staffId(res), { ...(req.body as Omit<CreateAmcInput, 'clientId'>), clientId: id })
res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.patch('/amc/:id', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
try {
const amc = updateAmc(db, staffId(res), id, req.body as AmcPatch)
res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/amc/:id/deactivate', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
const amc = deactivateAmc(db, staffId(res), id)
res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
})
r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
try {
res.json({ ok: true, document: generateAmcRenewalInvoice(db, staffId(res), id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
```
- [ ] **Step 4: Run tests**`npx vitest run apps/hq/test/amc.test.ts` → PASS (verify the golden `23_600_00` paisa). `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/repos-amc.ts apps/hq/src/api.ts apps/hq/test/amc.test.ts
git commit -m "feat(hq): amc contracts with renewal invoice and derived paid state" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 4: Interactions — types, log, follow-ups, routes
**Files:**
- Create: `apps/hq/src/repos-interactions.ts`
- Modify: `apps/hq/src/api.ts`
- Test: `apps/hq/test/interactions.test.ts`
**Interfaces:**
- Produces:
- `InteractionType = { code: string; label: string }`; `listInteractionTypes(db): InteractionType[]`; `createInteractionType(db, userId, { code, label }): InteractionType` (owner-only at the route; the lookup is user-extensible).
- `Interaction = { id: string; clientId: string; typeCode: string; onDate: string; staffId: string; notes: string; outcome: 'positive'|'neutral'|'negative' | null; followUpOn: string | null; createdAt: string }`
- `createInteraction(db, userId, input: { clientId; typeCode; onDate; notes?; outcome?; followUpOn? }): Interaction` — validates the client exists and `typeCode` is a known type; `staffId` is the acting user.
- `listInteractions(db, clientId): Interaction[]` (newest first — the timeline).
- `updateInteraction(db, userId, id, patch: { notes?; outcome?; followUpOn? }): Interaction`.
- `listOpenFollowUps(db, onOrBefore: string): (Interaction & { clientName: string })[]` — interactions whose `follow_up_on <= onOrBefore`, newest due first (feeds the dashboard + the `follow_up` reminder rule).
- Routes: `GET /api/interaction-types`, `POST /api/interaction-types` (owner), `GET /api/clients/:id/interactions`, `POST /api/clients/:id/interactions`, `PATCH /api/interactions/:id`.
- [ ] **Step 1: Write the failing test**
```ts
// apps/hq/test/interactions.test.ts
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)
})
})
```
- [ ] **Step 2: Run to verify FAIL**`npx vitest run apps/hq/test/interactions.test.ts` → FAIL.
- [ ] **Step 3: Implement `apps/hq/src/repos-interactions.ts`**
```ts
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 }))
}
```
Wire routes in `api.ts` (import + block; type-create owner-gated, logging is staff-allowed):
```ts
import {
createInteraction, createInteractionType, getInteraction, listInteractions,
listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch,
} from './repos-interactions'
```
```ts
// ---------- 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<CreateInteractionInput, 'clientId'>), 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) })
}
})
```
- [ ] **Step 4: Run tests**`npx vitest run apps/hq/test/interactions.test.ts` → PASS. `npm run typecheck` → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq/src/repos-interactions.ts apps/hq/src/api.ts apps/hq/test/interactions.test.ts
git commit -m "feat(hq): interaction log with types, follow-ups and routes" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---