// apps/hq/test/pipeline.test.ts — Phase 5: pipeline chase-list (D-PIPE option B). // Union of latest-quotation-per-client + bare leads; derived stage/age/next-action/band // (bands from resolveSchedule — never hardcoded 3/7/14); staff server-forced to own rows; // actionable-oldest-first sort; LIMIT/OFFSET pagination with total. import express from 'express' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { openDb, type DB } from '../src/db' import { createStaff } from '../src/auth' import { createClient } from '../src/repos-clients' import { createModule, setPrice } from '../src/repos-modules' import { createDraft, convertDocument, markStatus } from '../src/repos-documents' import { listPipeline } from '../src/repos-pipeline' import { apiRouter } from '../src/api' const TODAY = '2026-07-17' const OWNER_VIEW = { viewerRole: 'owner', viewerId: 'boss', today: TODAY } as const function daysAgo(n: number): string { return new Date(Date.parse(TODAY) - n * 86_400_000).toISOString() } async function setup(): Promise<{ db: DB; moduleId: string }> { const db = openDb(':memory:') await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`) await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`) const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) return { db, moduleId: m.id } } /** Draft QUOTATION for the client; optionally marked sent with the event backdated `ageDays`. */ async function quote(db: DB, clientId: string, moduleId: string, opts: { by?: string; sentAgeDays?: number } = {}) { const by = opts.by ?? 'u1' const d = await createDraft(db, by, { docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }], }) if (opts.sentAgeDays !== undefined) { await markStatus(db, by, d.id, 'sent') await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`, daysAgo(opts.sentAgeDays), d.id) } return d } describe('listPipeline — stage derivation (spec §9)', () => { it('a bare lead with no quotation appears as Enquiry owned by client.owner_id', async () => { const { db, moduleId } = await setup() const emp = await createStaff(db, { email: 'v@x.co', displayName: 'Vikram', role: 'staff', password: 'password2' }) const lead = await createClient(db, 'u1', { name: 'Nagari Sah.', stateCode: '32', status: 'lead' }) await db.run(`UPDATE client SET owner_id=? WHERE id=?`, emp.id, lead.id) // an active client with no quotation is NOT pipeline material await createClient(db, 'u1', { name: 'Steady Customer', stateCode: '32', status: 'active' }) // a lead WITH a quotation shows as its quote row, not a duplicate enquiry const quotedLead = await createClient(db, 'u1', { name: 'Quoted Lead', stateCode: '32', status: 'lead' }) await quote(db, quotedLead.id, moduleId) const out = await listPipeline(db, { ...OWNER_VIEW }) const enquiry = out.rows.find((r) => r.clientId === lead.id) expect(enquiry).toMatchObject({ stage: 'enquiry', nextAction: 'send_quote', docId: null, amountPaise: null, ownerId: emp.id, ownerName: 'Vikram', ageDays: null, band: null, }) expect(out.rows.filter((r) => r.clientId === quotedLead.id)).toHaveLength(1) expect(out.rows.find((r) => r.clientId === quotedLead.id)!.stage).toBe('new_project') expect(out.rows.some((r) => r.clientName === 'Steady Customer')).toBe(false) }) it('derives New Project / Quoted-Waiting / Won from the latest quotation status', async () => { const { db, moduleId } = await setup() const draft = await createClient(db, 'u1', { name: 'Draft Co', stateCode: '32', status: 'lead' }) await quote(db, draft.id, moduleId) const sent = await createClient(db, 'u1', { name: 'Sent Co', stateCode: '32', status: 'lead' }) await quote(db, sent.id, moduleId, { sentAgeDays: 1 }) const accepted = await createClient(db, 'u1', { name: 'Accepted Co', stateCode: '32' }) const qa = await quote(db, accepted.id, moduleId, { sentAgeDays: 2 }) await markStatus(db, 'u1', qa.id, 'accepted') const invoiced = await createClient(db, 'u1', { name: 'Invoiced Co', stateCode: '32' }) const qi = await quote(db, invoiced.id, moduleId, { sentAgeDays: 2 }) await markStatus(db, 'u1', qi.id, 'accepted') await convertDocument(db, 'u1', qi.id, 'INVOICE') const by = new Map((await listPipeline(db, { ...OWNER_VIEW })).rows.map((r) => [r.clientName, r])) expect(by.get('Draft Co')).toMatchObject({ stage: 'new_project', nextAction: 'send_quote' }) expect(by.get('Sent Co')).toMatchObject({ stage: 'quoted_waiting', ageDays: 1 }) expect(by.get('Sent Co')!.amountPaise).toBe(11_800_00) expect(by.get('Accepted Co')).toMatchObject({ stage: 'won', nextAction: 'convert_invoice' }) expect(by.get('Invoiced Co')).toMatchObject({ stage: 'won', nextAction: 'none' }) }) it('the LATEST quotation wins when a client has several', async () => { const { db, moduleId } = await setup() const c = await createClient(db, 'u1', { name: 'Two Quotes', stateCode: '32' }) const old = await quote(db, c.id, moduleId, { sentAgeDays: 40 }) await markStatus(db, 'u1', old.id, 'lost') await quote(db, c.id, moduleId, { sentAgeDays: 2 }) const rows = (await listPipeline(db, { ...OWNER_VIEW })).rows.filter((r) => r.clientId === c.id) expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ stage: 'quoted_waiting', ageDays: 2 }) }) it('Lost (quote lost or client lost) is hidden unless filtered', async () => { const { db, moduleId } = await setup() const lostQuote = await createClient(db, 'u1', { name: 'Lost Quote Co', stateCode: '32' }) const q = await quote(db, lostQuote.id, moduleId, { sentAgeDays: 5 }) await markStatus(db, 'u1', q.id, 'lost') const lostClient = await createClient(db, 'u1', { name: 'Lost Client Co', stateCode: '32', status: 'lost' }) await quote(db, lostClient.id, moduleId, { sentAgeDays: 5 }) const alive = await createClient(db, 'u1', { name: 'Alive Co', stateCode: '32' }) await quote(db, alive.id, moduleId, { sentAgeDays: 1 }) const all = await listPipeline(db, { ...OWNER_VIEW }) expect(all.rows.map((r) => r.clientName)).toEqual(['Alive Co']) expect(all.total).toBe(1) const lost = await listPipeline(db, { ...OWNER_VIEW, filter: 'lost' }) expect(lost.rows.map((r) => r.stage)).toEqual(['lost', 'lost']) expect(lost.total).toBe(2) }) it('a quote-less LOST client is reviewable under filter=lost (spec §9), hidden otherwise', async () => { const { db } = await setup() await createClient(db, 'u1', { name: 'Quote-less Lost Co', stateCode: '32', status: 'lost' }) expect((await listPipeline(db, { ...OWNER_VIEW })).total).toBe(0) const lost = await listPipeline(db, { ...OWNER_VIEW, filter: 'lost' }) expect(lost.total).toBe(1) expect(lost.rows[0]).toMatchObject({ clientName: 'Quote-less Lost Co', stage: 'lost', nextAction: 'none', docId: null, }) }) it('QT→PI conversion moves the client out of Quoted/Waiting — no stuck chase row', async () => { const { db, moduleId } = await setup() const c = await createClient(db, 'u1', { name: 'Converted Co', stateCode: '32' }) const q = await quote(db, c.id, moduleId, { sentAgeDays: 10 }) await convertDocument(db, 'u1', q.id, 'PROFORMA') const rows = (await listPipeline(db, { ...OWNER_VIEW })).rows.filter((r) => r.clientId === c.id) expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ stage: 'won', nextAction: 'none' }) // moved forward, no chase buttons }) }) describe('listPipeline — next action + band from resolveSchedule (no hardcoded 3/7/14)', () => { it('default offsets 3/7/14: waiting<3, chase>=3, nudge>=7, final nudge>=14; bands green/amber/red', async () => { const { db, moduleId } = await setup() const mk = async (name: string, age: number) => { const c = await createClient(db, 'u1', { name, stateCode: '32' }) await quote(db, c.id, moduleId, { sentAgeDays: age }) } await mk('Age2', 2); await mk('Age5', 5); await mk('Age8', 8); await mk('Age15', 15) const out = await listPipeline(db, { ...OWNER_VIEW }) expect(out.dayOffsets).toEqual([3, 7, 14]) const by = new Map(out.rows.map((r) => [r.clientName, r])) expect(by.get('Age2')).toMatchObject({ nextAction: 'waiting', band: 'green', ageDays: 2 }) expect(by.get('Age5')).toMatchObject({ nextAction: 'chase', band: 'amber', ageDays: 5 }) expect(by.get('Age8')).toMatchObject({ nextAction: 'nudge', band: 'amber', ageDays: 8 }) expect(by.get('Age15')).toMatchObject({ nextAction: 'final_nudge', band: 'red', ageDays: 15 }) }) it('a dated reminder_schedule row changes the bands without a code edit', async () => { const { db, moduleId } = await setup() await db.run( `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) VALUES ('rs1', 'quote_followup', '2026-01-01', NULL, '2,4,6', NULL, NULL)`, ) const c = await createClient(db, 'u1', { name: 'Fast Lane', stateCode: '32' }) await quote(db, c.id, moduleId, { sentAgeDays: 5 }) const out = await listPipeline(db, { ...OWNER_VIEW }) expect(out.dayOffsets).toEqual([2, 4, 6]) // age 5 crossed 2 and 4 but not 6 → nudge, amber (would be 'chase' under 3/7/14) expect(out.rows[0]).toMatchObject({ nextAction: 'nudge', band: 'amber' }) }) }) describe('listPipeline — role gate (staff server-forced to own rows)', () => { async function team(db: DB) { const a = await createStaff(db, { email: 'a@x.co', displayName: 'Asha', role: 'staff', password: 'password2' }) const b = await createStaff(db, { email: 'b@x.co', displayName: 'Priya', role: 'staff', password: 'password2' }) return { a: a.id, b: b.id } } it('staff see only quotes they created and leads they own; widening params are ignored', async () => { const { db, moduleId } = await setup() const { a, b } = await team(db) const c1 = await createClient(db, 'u1', { name: 'Asha Quote Co', stateCode: '32' }) await quote(db, c1.id, moduleId, { by: a, sentAgeDays: 4 }) const c2 = await createClient(db, 'u1', { name: 'Priya Quote Co', stateCode: '32' }) await quote(db, c2.id, moduleId, { by: b, sentAgeDays: 4 }) const leadA = await createClient(db, 'u1', { name: 'Asha Lead', stateCode: '32', status: 'lead' }) await db.run(`UPDATE client SET owner_id=? WHERE id=?`, a, leadA.id) await createClient(db, 'u1', { name: 'Unassigned Lead', stateCode: '32', status: 'lead' }) const mine = await listPipeline(db, { viewerRole: 'staff', viewerId: a, today: TODAY }) expect(mine.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co']) // a widening owner param cannot escape the self scope const widened = await listPipeline(db, { viewerRole: 'staff', viewerId: a, ownerId: b, today: TODAY }) expect(widened.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co']) // owner/manager see everything, including the unassigned lead (F13 safety net) const boss = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', today: TODAY }) expect(boss.total).toBe(4) // ...and may narrow to one owner, or to themselves via filter=mine const narrowed = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', ownerId: b, today: TODAY }) expect(narrowed.rows.map((r) => r.clientName)).toEqual(['Priya Quote Co']) const bossMine = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', filter: 'mine', today: TODAY }) expect(bossMine.total).toBe(0) }) }) describe('listPipeline — sort, overdue filter, pagination', () => { it('sorts actionable oldest first: overdue by age desc, then enquiry/draft, then won', async () => { const { db, moduleId } = await setup() const mkSent = async (name: string, age: number) => { const c = await createClient(db, 'u1', { name, stateCode: '32' }) return quote(db, c.id, moduleId, { sentAgeDays: age }) } await mkSent('Mid 5d', 5) await mkSent('Oldest 15d', 15) await mkSent('Waiting 2d', 2) await createClient(db, 'u1', { name: 'A Lead', stateCode: '32', status: 'lead' }) const won = await createClient(db, 'u1', { name: 'Won Co', stateCode: '32' }) await markStatus(db, 'u1', (await quote(db, won.id, moduleId, { sentAgeDays: 30 })).id, 'accepted') const names = (await listPipeline(db, { ...OWNER_VIEW })).rows.map((r) => r.clientName) expect(names).toEqual(['Oldest 15d', 'Mid 5d', 'Waiting 2d', 'A Lead', 'Won Co']) }) it('filter=overdue keeps only sent quotes past the first offset', async () => { const { db, moduleId } = await setup() const mkSent = async (name: string, age: number) => { const c = await createClient(db, 'u1', { name, stateCode: '32' }) await quote(db, c.id, moduleId, { sentAgeDays: age }) } await mkSent('Fresh 1d', 1) await mkSent('Over 9d', 9) await createClient(db, 'u1', { name: 'Some Lead', stateCode: '32', status: 'lead' }) const out = await listPipeline(db, { ...OWNER_VIEW, filter: 'overdue' }) expect(out.rows.map((r) => r.clientName)).toEqual(['Over 9d']) expect(out.total).toBe(1) }) it('paginates with LIMIT/OFFSET semantics and an honest total', async () => { const { db, moduleId } = await setup() for (let i = 0; i < 5; i += 1) { const c = await createClient(db, 'u1', { name: `Client ${i}`, stateCode: '32' }) await quote(db, c.id, moduleId, { sentAgeDays: 10 + i }) } const p1 = await listPipeline(db, { ...OWNER_VIEW, page: 1, pageSize: 2 }) expect(p1.total).toBe(5) expect(p1.rows).toHaveLength(2) expect(p1.rows[0]!.ageDays).toBe(14) // oldest first const p3 = await listPipeline(db, { ...OWNER_VIEW, page: 3, pageSize: 2 }) expect(p3.rows).toHaveLength(1) expect(p3.page).toBe(3) expect(p3.pageSize).toBe(2) // no overlap, nothing dropped const all = [ ...p1.rows, ...(await listPipeline(db, { ...OWNER_VIEW, page: 2, pageSize: 2 })).rows, ...p3.rows, ] expect(new Set(all.map((r) => r.clientId)).size).toBe(5) }) }) describe('GET /pipeline', () => { let server: ReturnType['listen']> let base: string beforeAll(async () => { const { db, moduleId } = await setup() await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) const staffDbId = (await db.get(`SELECT id FROM staff_user WHERE email='staff@test.in'`) as { id: string }).id const mineClient = await createClient(db, 'seed', { name: 'Staff Own Co', stateCode: '32' }) await quote(db, mineClient.id, moduleId, { by: staffDbId, sentAgeDays: 4 }) const otherClient = await createClient(db, 'seed', { name: 'Someone Else Co', stateCode: '32' }) await quote(db, otherClient.id, moduleId, { sentAgeDays: 4 }) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) server = app.listen(0) 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 get = async (token: string, qs: string) => { const res = await fetch(`${base}/pipeline${qs}`, { headers: { authorization: `Bearer ${token}` } }) return { status: res.status, json: await res.json() as any } } it('returns the derived rows with total/page/pageSize; staff are forced to their own rows', async () => { const owner = await tokenOf('owner@test.in', 'owner-password') const all = await get(owner, '') expect(all.status).toBe(200) expect(all.json.total).toBe(2) expect(all.json.page).toBe(1) expect(all.json.rows).toHaveLength(2) const staff = await tokenOf('staff@test.in', 'staff-password') const own = await get(staff, '') expect(own.json.total).toBe(1) expect(own.json.rows[0].clientName).toBe('Staff Own Co') // the widening ?owner= param is ignored server-side for staff const widened = await get(staff, `?owner=someone-else`) expect(widened.json.total).toBe(1) expect(widened.json.rows[0].clientName).toBe('Staff Own Co') }) it('rejects a bad filter; honours page/pageSize', async () => { const owner = await tokenOf('owner@test.in', 'owner-password') expect((await get(owner, '?filter=bogus')).status).toBe(400) const paged = await get(owner, '?page=2&pageSize=1') expect(paged.json.total).toBe(2) expect(paged.json.rows).toHaveLength(1) expect(paged.json.pageSize).toBe(1) }) })