From 06b5eb532c0392754c151d8b361f9b2c89f48ac6 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 15:45:28 +0530 Subject: [PATCH] feat(hq): dashboard money view and 6-hourly scheduler wiring Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 7 +++ apps/hq/src/repos-dashboard.ts | 97 ++++++++++++++++++++++++++++++++++ apps/hq/src/server.ts | 37 +++++++++++-- apps/hq/test/dashboard.test.ts | 70 ++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 apps/hq/src/repos-dashboard.ts create mode 100644 apps/hq/test/dashboard.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 0ced5be..b8c2d99 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -36,6 +36,7 @@ import { type ReminderStatus, } from './repos-reminders' import { sendReminder, type SendReminderDeps } from './send-reminder' +import { dashboardView } from './repos-dashboard' import { documentHtml } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' @@ -455,6 +456,12 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { } }) + // ---------- dashboard ---------- + r.get('/dashboard', requireAuth, (_req, res) => { + const today = new Date().toISOString().slice(0, 10) + res.json({ ok: true, view: dashboardView(db, today) }) + }) + // ---------- payments ---------- r.post('/payments', requireAuth, (req, res) => { try { diff --git a/apps/hq/src/repos-dashboard.ts b/apps/hq/src/repos-dashboard.ts new file mode 100644 index 0000000..6e5f870 --- /dev/null +++ b/apps/hq/src/repos-dashboard.ts @@ -0,0 +1,97 @@ +import type { DB } from './db' +import { outstandingPaise } from './repos-payments' +import { listOpenFollowUps } from './repos-interactions' +import { listQueue, type Reminder } from './repos-reminders' +import { addDaysIso } from './scheduler' + +/** Read-only money view for the dashboard home (spec ยง3 "today's money"). */ + +export interface DashboardView { + today: string + overdue: { docId: string; docNo: string | null; clientId: string; clientName: string; outstandingPaise: number; daysOverdue: number }[] + dueThisWeek: { planId: string; clientId: string; clientName: string; nextRun: string; amountPaise: number | null }[] + renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[] + followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[] + recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[] + queue: (Reminder & { clientName: string; docNo: string | null })[] + totals: { overduePaise: number; queued: number; failed: number } +} + +function daysBetween(fromIso: string, toIso: string): number { + return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000) +} + +function endOfMonth(today: string): string { + const [y, m] = today.split('-').map(Number) + return new Date(Date.UTC(y!, m!, 0)).toISOString().slice(0, 10) // day 0 of next month = last day of this +} + +export function dashboardView(db: DB, today: string): DashboardView { + const overdueRows = db.prepare( + `SELECT d.id, d.doc_no, d.client_id, d.doc_date, c.name AS client_name + FROM document d JOIN client c ON c.id = d.client_id + WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL + AND d.status NOT IN ('paid','cancelled','lost') AND d.doc_date <= ? + ORDER BY d.doc_date`, + ).all(today) as { id: string; doc_no: string | null; client_id: string; doc_date: string; client_name: string }[] + const overdue: DashboardView['overdue'] = [] + let overduePaise = 0 + for (const r of overdueRows) { + const outstanding = outstandingPaise(db, r.id) + if (outstanding <= 0) continue + overduePaise += outstanding + overdue.push({ + docId: r.id, docNo: r.doc_no, clientId: r.client_id, clientName: r.client_name, + outstandingPaise: outstanding, daysOverdue: Math.max(0, daysBetween(r.doc_date, today)), + }) + } + + const weekEnd = addDaysIso(today, 7) + const dueThisWeek = db.prepare( + `SELECT rp.id AS plan_id, rp.client_id, rp.next_run, rp.amount_paise, c.name AS client_name + FROM recurring_plan rp JOIN client c ON c.id = rp.client_id + WHERE rp.active=1 AND rp.next_run >= ? AND rp.next_run <= ? ORDER BY rp.next_run`, + ).all(today, weekEnd).map((r) => { + const row = r as { plan_id: string; client_id: string; next_run: string; amount_paise: number | null; client_name: string } + return { planId: row.plan_id, clientId: row.client_id, clientName: row.client_name, nextRun: row.next_run, amountPaise: row.amount_paise } + }) + + const renewalsThisMonth = db.prepare( + `SELECT cm.id AS cm_id, cm.client_id, cm.next_renewal, c.name AS client_name + FROM client_module cm JOIN client c ON c.id = cm.client_id + WHERE cm.active=1 AND cm.next_renewal >= ? AND cm.next_renewal <= ? ORDER BY cm.next_renewal`, + ).all(today, endOfMonth(today)).map((r) => { + const row = r as { cm_id: string; client_id: string; next_renewal: string; client_name: string } + return { clientModuleId: row.cm_id, clientId: row.client_id, clientName: row.client_name, nextRenewal: row.next_renewal } + }) + + const followUpsToday = listOpenFollowUps(db, today).map((f) => ({ + id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate, + followUpOn: f.followUpOn!, notes: f.notes, + })) + + const recentPayments = db.prepare( + `SELECT p.id, p.client_id, p.received_on, p.amount_paise, p.mode, c.name AS client_name + FROM payment p JOIN client c ON c.id = p.client_id ORDER BY p.id DESC LIMIT 10`, + ).all().map((r) => { + const row = r as { id: string; client_id: string; received_on: string; amount_paise: number; mode: string; client_name: string } + return { id: row.id, clientId: row.client_id, clientName: row.client_name, receivedOn: row.received_on, amountPaise: row.amount_paise, mode: row.mode } + }) + + const queue = listQueue(db).map((rem) => { + const client = db.prepare(`SELECT name FROM client WHERE id=?`).get(rem.clientId) as { name: string } | undefined + const doc = rem.docId !== null + ? db.prepare(`SELECT doc_no FROM document WHERE id=?`).get(rem.docId) as { doc_no: string | null } | undefined + : undefined + return { ...rem, clientName: client?.name ?? '', docNo: doc?.doc_no ?? null } + }) + + return { + today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments, queue, + totals: { + overduePaise, + queued: queue.filter((q) => q.status === 'queued').length, + failed: queue.filter((q) => q.status === 'failed').length, + }, + } +} diff --git a/apps/hq/src/server.ts b/apps/hq/src/server.ts index bfd164a..d766f1b 100644 --- a/apps/hq/src/server.ts +++ b/apps/hq/src/server.ts @@ -4,7 +4,10 @@ import type http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' import { apiRouter } from './api' -import { openDb } from './db' +import { pollBounces } from './bounces' +import { openDb, type DB } from './db' +import { renderPdf } from './pdf' +import { runDailyScan } from './scheduler' import { seedIfEmpty } from './seed' // dist/server.cjs (esbuild CJS bundle) has __dirname; vitest/tsx run this file as ESM. @@ -13,7 +16,34 @@ const moduleDir = typeof __dirname === 'undefined' ? path.dirname(fileURLToPath(import.meta.url)) : __dirname -export function startServer(port: number): http.Server { +function schedulerDeps(db: DB) { + const company = (): Record => + Object.fromEntries((db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`) + .all() as { key: string; value: string }[]).map((r) => [r.key, r.value])) + const gmail = { + f: fetch, + clientId: process.env['GOOGLE_CLIENT_ID'] ?? '', + clientSecret: process.env['GOOGLE_CLIENT_SECRET'] ?? '', + keyHex: process.env['HQ_SECRET_KEY'] ?? '', + } + return { gmail, renderPdf, company } +} + +/** Boot the scan + bounce poll now and every 6h. unref() so it never blocks exit. */ +export function startScheduler(db: DB): NodeJS.Timeout { + const deps = schedulerDeps(db) + const today = (): string => new Date().toISOString().slice(0, 10) + const tick = (): void => { + void runDailyScan(db, deps, today()).catch((e: unknown) => console.error('[scheduler] scan failed', e)) + void pollBounces(db, deps).catch((e: unknown) => console.error('[scheduler] bounce poll failed', e)) + } + tick() + const handle = setInterval(tick, 6 * 60 * 60 * 1000) + handle.unref() + return handle +} + +export function startServer(port: number, opts: { scheduler?: boolean } = {}): http.Server { const app = express() const db = openDb(process.env['HQ_DATA_DIR']) seedIfEmpty(db) // first boot prints the owner password once @@ -22,12 +52,13 @@ export function startServer(port: number): http.Server { app.use('/api', apiRouter(db)) const webDist = path.resolve(moduleDir, '../../hq-web/dist') if (fs.existsSync(webDist)) app.use('/', express.static(webDist)) + if (opts.scheduler === true) startScheduler(db) return app.listen(port) } // Direct launch (bundled dist/server.cjs); vitest imports startServer instead. if (process.argv[1]?.endsWith('server.cjs') || process.argv[1]?.endsWith('server.ts')) { const PORT = Number(process.env['HQ_PORT'] ?? 5182) - startServer(PORT) + startServer(PORT, { scheduler: true }) console.log(`SiMS HQ console on http://localhost:${PORT}`) } diff --git a/apps/hq/test/dashboard.test.ts b/apps/hq/test/dashboard.test.ts new file mode 100644 index 0000000..66870a7 --- /dev/null +++ b/apps/hq/test/dashboard.test.ts @@ -0,0 +1,70 @@ +// apps/hq/test/dashboard.test.ts +import { describe, it, expect, afterAll } from 'vitest' +import express from 'express' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { apiRouter } from '../src/api' +import { createClient } from '../src/repos-clients' +import { assignModule, createModule, setPrice, updateClientModule } from '../src/repos-modules' +import { createDraft, issueDocument } from '../src/repos-documents' +import { createInteraction } from '../src/repos-interactions' +import { dashboardView } from '../src/repos-dashboard' +import { runDailyScan, type ScanDeps } from '../src/scheduler' + +const scanDeps: ScanDeps = { + gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' }, + renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z', +} + +function seeded() { + const db = openDb(':memory:'); seedIfEmpty(db) + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) + db.prepare(`UPDATE document SET doc_date='2026-06-01' WHERE id=?`).run(inv.id) + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' }) + createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' }) + return { db, c, inv } +} + +describe('dashboardView', () => { + it('aggregates overdue, renewals, follow-ups and the reminder queue', async () => { + const { db, inv } = seeded() + await runDailyScan(db, scanDeps, '2026-07-10') // populates the queue + const view = dashboardView(db, '2026-07-10') + expect(view.overdue.map((o) => o.docId)).toContain(inv.id) + expect(view.overdue[0]!.outstandingPaise).toBe(11_800_00) + expect(view.renewalsThisMonth).toHaveLength(1) + expect(view.followUpsToday).toHaveLength(1) + expect(view.queue.length).toBeGreaterThanOrEqual(3) // overdue + renewal + follow_up + expect(view.totals.overduePaise).toBe(11_800_00) + }) +}) + +describe('GET /api/dashboard + reminder queue routes', () => { + const { db } = seeded() + createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-password' }) + const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + let token = '' + const call = async (method: string, path: string, body?: unknown) => { + const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) }) + return { status: res.status, json: await res.json() as any } + } + afterAll(() => server.close()) + it('serves the dashboard, lists the queue and dismisses a reminder', async () => { + token = (await call('POST', '/auth/login', { email: 'e2e@test.in', password: 'e2e-password' })).json.token + await runDailyScan(db, scanDeps, '2026-07-10') + const dash = (await call('GET', '/dashboard')).json + expect(dash.ok).toBe(true) + expect(dash.view.overdue.length).toBeGreaterThanOrEqual(1) + const queue = (await call('GET', '/reminders')).json.reminders + expect(queue.length).toBeGreaterThanOrEqual(1) + const dismissed = (await call('POST', `/reminders/${queue[0].id}/dismiss`)).json + expect(dismissed.reminder.status).toBe('dismissed') + }) +})