feat(client-detail): quotation-step document link + ticket classification (backend)
Backend half of the Client Detail redesign follow-ups (#7, #6, #4). #7 — quotation step links its document - project_milestone gains a nullable `document_id`, landed on BOTH engines: a PRAGMA-guarded ALTER in db.ts migrate() and pg migration 016. - setMilestone(..., doneOn?, paymentId?, documentId?) validates the document belongs to the same client as the project (mirroring the paymentId check), stores it on tick and clears it on untick; Milestone gains `documentId`. - POST /client-modules/:id/milestones accepts `documentId`. - migrateOnboardingTemplates carries document_id across the template swap so a linked quotation is not lost. #6 — ticket classification - ticket gains `type TEXT NOT NULL DEFAULT 'service'` (db.ts SCHEMA + guarded ALTER, pg migration 017); imported APEX rows fall to the default. - Config over code: the list is the `ticket.types` setting (seeded on first boot) resolved by ticketTypes(), with TICKET_TYPE_FALLBACK in code. - GET /tickets/types; create/update accept + validate `type`; list rows carry it and GET /tickets?type= filters (blank/omitted = every type). #4 — cleanup - Refreshed the stale file comment atop repos-milestones.ts and the project_milestone comment in db.ts: both now describe the composed front + per-module tail model (they still described the old flat template). - Migration tests for the interim keys advance_payment / balance_payment -> payment_received, plus the document-link carry. npm run typecheck clean; npm test 473 passed / 5 skipped (was 453/5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
83633c86ee
commit
7838ced6c2
@ -0,0 +1,122 @@
|
||||
// apps/hq/test/milestone-document.test.ts — Client Detail redesign task 7: the quotation
|
||||
// step (`quotation_sent`) can link the quotation / proforma document that was actually sent.
|
||||
import express from 'express'
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { apiRouter } from '../src/api'
|
||||
import { createStaff } from '../src/auth'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, setPrice, assignModule } from '../src/repos-modules'
|
||||
import { createDraft } from '../src/repos-documents'
|
||||
import { setMilestone, listProjectMilestones } from '../src/repos-milestones'
|
||||
|
||||
const KEY = '11'.repeat(32)
|
||||
|
||||
async function world() {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||
const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' })
|
||||
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' })
|
||||
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
const quote = async (clientId: string) => createDraft(db, 'u1', {
|
||||
docType: 'QUOTATION', clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
|
||||
})
|
||||
return { db, clientId: c.id, cmId: cm.id, quote }
|
||||
}
|
||||
|
||||
const step = async (db: Awaited<ReturnType<typeof world>>['db'], cmId: string) =>
|
||||
(await listProjectMilestones(db, cmId)).find((s) => s.key === 'quotation_sent')!
|
||||
|
||||
describe('milestone document_id link (quotation step)', () => {
|
||||
it('linking a quotation stores document_id on the ticked step', async () => {
|
||||
const { db, clientId, cmId, quote } = await world()
|
||||
const doc = await quote(clientId)
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
|
||||
const s = await step(db, cmId)
|
||||
expect(s.done).toBe(true)
|
||||
expect(s.doneOn).toBe('2026-05-01')
|
||||
expect(s.documentId).toBe(doc.id)
|
||||
})
|
||||
|
||||
it('rejects a document that belongs to a different client', async () => {
|
||||
const { db, cmId, quote } = await world()
|
||||
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
||||
const foreign = await quote(other.id)
|
||||
await expect(
|
||||
setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, foreign.id),
|
||||
).rejects.toThrow(/document/i)
|
||||
// …and nothing was written: the step is still pending.
|
||||
const s = await step(db, cmId)
|
||||
expect(s.done).toBe(false)
|
||||
expect(s.documentId).toBeNull()
|
||||
})
|
||||
|
||||
it('unticking clears document_id along with done_on', async () => {
|
||||
const { db, clientId, cmId, quote } = await world()
|
||||
const doc = await quote(clientId)
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
|
||||
expect((await step(db, cmId)).documentId).toBe(doc.id)
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', false)
|
||||
const s = await step(db, cmId)
|
||||
expect(s.done).toBe(false)
|
||||
expect(s.doneOn).toBeNull()
|
||||
expect(s.documentId).toBeNull()
|
||||
})
|
||||
|
||||
it('ticking without a documentId leaves the link null (opt-in)', async () => {
|
||||
const { db, cmId } = await world()
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true)
|
||||
const s = await step(db, cmId)
|
||||
expect(s.done).toBe(true)
|
||||
expect(s.documentId).toBeNull()
|
||||
})
|
||||
|
||||
it('audits the link in the same transaction as the tick', async () => {
|
||||
const { db, clientId, cmId, quote } = await world()
|
||||
const doc = await quote(clientId)
|
||||
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id)
|
||||
const audits = await db.all<{ after_json: string | null }>(
|
||||
`SELECT after_json FROM audit_log WHERE action='set_milestone'`,
|
||||
)
|
||||
expect(audits).toHaveLength(1)
|
||||
expect(audits[0]!.after_json).toContain(doc.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('milestone route accepts documentId', () => {
|
||||
const servers: { close: () => void }[] = []
|
||||
afterAll(() => { for (const s of servers) s.close() })
|
||||
|
||||
it('POST /client-modules/:id/milestones threads documentId through', async () => {
|
||||
const { db, clientId, cmId, quote } = await world()
|
||||
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
|
||||
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||
app.use('/api', apiRouter(db, { keyHex: KEY }))
|
||||
const server = app.listen(0); servers.push(server)
|
||||
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
|
||||
})).json()) as { token: string }).token
|
||||
const H = { 'content-type': 'application/json', authorization: `Bearer ${token}` }
|
||||
|
||||
const doc = await quote(clientId)
|
||||
const out = await (await fetch(`${base}/client-modules/${cmId}/milestones`, {
|
||||
method: 'POST', headers: H,
|
||||
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: doc.id }),
|
||||
})).json() as { ok: boolean; milestones: { key: string; documentId: string | null }[] }
|
||||
expect(out.ok).toBe(true)
|
||||
expect(out.milestones.find((m) => m.key === 'quotation_sent')!.documentId).toBe(doc.id)
|
||||
|
||||
// A foreign document is a 400, not a silent link.
|
||||
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
|
||||
const foreign = await quote(other.id)
|
||||
const bad = await fetch(`${base}/client-modules/${cmId}/milestones`, {
|
||||
method: 'POST', headers: H,
|
||||
body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: foreign.id }),
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,161 @@
|
||||
// apps/hq/test/ticket-types.test.ts — Client Detail redesign task 6: ticket classification.
|
||||
// The list is config over code (the `ticket.types` setting, code fallback); tickets carry
|
||||
// a `type`, the desk filters by it.
|
||||
import express from 'express'
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { openDb, type DB } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { apiRouter } from '../src/api'
|
||||
import { createStaff } from '../src/auth'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { setSetting } from '../src/repos-reminders'
|
||||
import {
|
||||
createTicket, updateTicket, listTickets, ticketTypes, TICKET_TYPE_FALLBACK,
|
||||
} from '../src/repos-tickets'
|
||||
|
||||
const KEY = '11'.repeat(32)
|
||||
|
||||
async function world() {
|
||||
const db = openDb(':memory:')
|
||||
await seedIfEmpty(db)
|
||||
const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' })
|
||||
return { db, c }
|
||||
}
|
||||
|
||||
describe('ticket type list (config over code)', () => {
|
||||
it('seeds `ticket.types` on first boot, matching the code fallback', async () => {
|
||||
const { db } = await world()
|
||||
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='ticket.types'`)
|
||||
expect(row).toBeDefined()
|
||||
expect(JSON.parse(row!.value)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
})
|
||||
|
||||
it('the resolver prefers the setting over the code fallback', async () => {
|
||||
const { db } = await world()
|
||||
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([
|
||||
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
|
||||
]))
|
||||
expect(await ticketTypes(db)).toEqual([
|
||||
{ key: 'service', label: 'Service call' }, { key: 'audit', label: 'Audit visit' },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the code list when the setting is missing, empty or unparseable', async () => {
|
||||
const { db } = await world()
|
||||
await db.run(`DELETE FROM setting WHERE key='ticket.types'`)
|
||||
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
await setSetting(db, 'u1', 'ticket.types', '[]')
|
||||
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
await setSetting(db, 'u1', 'ticket.types', 'not json at all')
|
||||
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
await setSetting(db, 'u1', 'ticket.types', JSON.stringify([{ label: 'no key' }]))
|
||||
expect(await ticketTypes(db)).toEqual(TICKET_TYPE_FALLBACK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ticket.type on create / update / list', () => {
|
||||
it("defaults to 'service' when the caller sends no type", async () => {
|
||||
const { db, c } = await world()
|
||||
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'no type given' })
|
||||
expect(t.type).toBe('service')
|
||||
})
|
||||
|
||||
it('stores a configured type and rejects an unknown one', async () => {
|
||||
const { db, c } = await world()
|
||||
const t = await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'renewal' })
|
||||
expect(t.type).toBe('amc')
|
||||
await expect(createTicket(db, 'u1', { clientId: c.id, type: 'nonsense' })).rejects.toThrow(/type/i)
|
||||
})
|
||||
|
||||
it('updates the type (audited) and rejects an unknown one', async () => {
|
||||
const { db, c } = await world()
|
||||
const t = await createTicket(db, 'u1', { clientId: c.id, description: 'reclassify me' })
|
||||
const after = await updateTicket(db, 'u1', t.id, { type: 'modification' })
|
||||
expect(after.type).toBe('modification')
|
||||
await expect(updateTicket(db, 'u1', t.id, { type: 'nonsense' })).rejects.toThrow(/type/i)
|
||||
// still 'modification' — the rejected patch wrote nothing
|
||||
expect((await listTickets(db, { clientId: c.id })).tickets[0]!.type).toBe('modification')
|
||||
})
|
||||
|
||||
it('filters the list by type; a blank/omitted filter returns every type', async () => {
|
||||
const { db, c } = await world()
|
||||
await createTicket(db, 'u1', { clientId: c.id, type: 'service', description: 'a' })
|
||||
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'b' })
|
||||
await createTicket(db, 'u1', { clientId: c.id, type: 'amc', description: 'c' })
|
||||
expect((await listTickets(db, { type: 'amc' })).total).toBe(2)
|
||||
expect((await listTickets(db, { type: 'service' })).total).toBe(1)
|
||||
expect((await listTickets(db, { type: 'modification' })).total).toBe(0)
|
||||
expect((await listTickets(db, { type: '' })).total).toBe(3)
|
||||
expect((await listTickets(db, {})).total).toBe(3)
|
||||
})
|
||||
|
||||
it('an existing (pre-migration) row reads back as the default type', async () => {
|
||||
const { db, c } = await world()
|
||||
// Insert the way the APEX importer does — without naming the new column.
|
||||
await db.run(
|
||||
`INSERT INTO ticket (id, client_id, kind, description, opened_on, created_by, created_at, source)
|
||||
VALUES ('legacy-1', ?, 'MODIFICATION', 'imported', '2025-01-01', 'u1', '2025-01-01T00:00:00.000Z', 'apex')`,
|
||||
c.id,
|
||||
)
|
||||
const listed = await listTickets(db, { clientId: c.id })
|
||||
expect(listed.tickets[0]!.type).toBe('service')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ticket type routes', () => {
|
||||
const servers: { close: () => void }[] = []
|
||||
afterAll(() => { for (const s of servers) s.close() })
|
||||
|
||||
async function httpWorld(): Promise<{ db: DB; clientId: string; base: string; H: Record<string, string> }> {
|
||||
const { db, c } = await world()
|
||||
await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' })
|
||||
const app = express(); app.use(express.json()); app.locals['db'] = db
|
||||
app.use('/api', apiRouter(db, { keyHex: KEY }))
|
||||
const server = app.listen(0); servers.push(server)
|
||||
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
const token = ((await (await fetch(`${base}/auth/login`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 't@t.in', password: 'password-1' }),
|
||||
})).json()) as { token: string }).token
|
||||
return { db, clientId: c.id, base, H: { 'content-type': 'application/json', authorization: `Bearer ${token}` } }
|
||||
}
|
||||
|
||||
it('GET /tickets/types serves the configured list', async () => {
|
||||
const { base, H } = await httpWorld()
|
||||
const out = await (await fetch(`${base}/tickets/types`, { headers: H })).json() as
|
||||
{ ok: boolean; types: { key: string; label: string }[] }
|
||||
expect(out.ok).toBe(true)
|
||||
expect(out.types).toEqual(TICKET_TYPE_FALLBACK)
|
||||
})
|
||||
|
||||
it('POST /tickets accepts a type, and GET /tickets?type= filters on it', async () => {
|
||||
const { clientId, base, H } = await httpWorld()
|
||||
for (const type of ['service', 'amc', 'amc']) {
|
||||
const created = await (await fetch(`${base}/tickets`, {
|
||||
method: 'POST', headers: H, body: JSON.stringify({ clientId, type, description: type }),
|
||||
})).json() as { ok: boolean; ticket: { type: string } }
|
||||
expect(created.ticket.type).toBe(type)
|
||||
}
|
||||
const amc = await (await fetch(`${base}/tickets?type=amc`, { headers: H })).json() as
|
||||
{ total: number; tickets: { type: string }[] }
|
||||
expect(amc.total).toBe(2)
|
||||
expect(amc.tickets.every((t) => t.type === 'amc')).toBe(true)
|
||||
const all = await (await fetch(`${base}/tickets?type=`, { headers: H })).json() as { total: number }
|
||||
expect(all.total).toBe(3)
|
||||
})
|
||||
|
||||
it('PATCH /tickets/:id reclassifies; an unknown type is a 400', async () => {
|
||||
const { clientId, base, H } = await httpWorld()
|
||||
const created = await (await fetch(`${base}/tickets`, {
|
||||
method: 'POST', headers: H, body: JSON.stringify({ clientId, description: 'x' }),
|
||||
})).json() as { ticket: { id: string } }
|
||||
const patched = await (await fetch(`${base}/tickets/${created.ticket.id}`, {
|
||||
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'module' }),
|
||||
})).json() as { ticket: { type: string } }
|
||||
expect(patched.ticket.type).toBe('module')
|
||||
const bad = await fetch(`${base}/tickets/${created.ticket.id}`, {
|
||||
method: 'PATCH', headers: H, body: JSON.stringify({ type: 'nonsense' }),
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue