feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view

Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 17:21:55 +02:00
parent 2fa70f4582
commit f52d21df83
63 changed files with 4459 additions and 206 deletions

View File

@@ -0,0 +1,153 @@
/**
* PR6 — documents hub `eoi_queue` tab.
*
* Verifies that:
* - `listDocuments` with tab='eoi_queue' returns only EOI docs in
* draft/sent/partially_signed status
* - `getHubTabCounts` reports the correct eoi_queue count
* - Other doc types (NDA, contract, welcome_letter) are excluded
* - Completed/expired EOIs are excluded (those belong to other tabs)
*/
import { describe, it, expect } from 'vitest';
import { db } from '@/lib/db';
import { documents } from '@/lib/db/schema/documents';
import { getHubTabCounts, listDocuments } from '@/lib/services/documents.service';
import { makePort, makeClient } from '../helpers/factories';
describe('documents hub — eoi_queue tab', () => {
it('lists only EOIs in in-flight status', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// Seed a mix: 2 in-flight EOIs, 1 completed EOI, 1 sent NDA, 1 sent welcome_letter.
await db.insert(documents).values([
{
portId: port.id,
clientId: client.id,
documentType: 'eoi',
title: 'EOI #1',
status: 'sent',
createdBy: 'seed',
},
{
portId: port.id,
clientId: client.id,
documentType: 'eoi',
title: 'EOI #2',
status: 'partially_signed',
createdBy: 'seed',
},
{
portId: port.id,
clientId: client.id,
documentType: 'eoi',
title: 'EOI #3 (done)',
status: 'completed',
createdBy: 'seed',
},
{
portId: port.id,
clientId: client.id,
documentType: 'nda',
title: 'NDA',
status: 'sent',
createdBy: 'seed',
},
{
portId: port.id,
clientId: client.id,
documentType: 'welcome_letter',
title: 'Welcome',
status: 'sent',
createdBy: 'seed',
},
]);
const result = await listDocuments(
port.id,
{
page: 1,
limit: 50,
sort: 'createdAt',
order: 'desc',
includeArchived: false,
tab: 'eoi_queue',
},
{},
);
const docs = result.data as Array<{ documentType: string; status: string }>;
expect(docs).toHaveLength(2);
expect(docs.every((d) => d.documentType === 'eoi')).toBe(true);
expect(docs.every((d) => ['sent', 'partially_signed'].includes(d.status))).toBe(true);
});
it('reports the correct eoi_queue count via getHubTabCounts', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await db.insert(documents).values([
{
portId: port.id,
clientId: client.id,
documentType: 'eoi',
title: 'EOI A',
status: 'draft',
createdBy: 'seed',
},
{
portId: port.id,
clientId: client.id,
documentType: 'eoi',
title: 'EOI B',
status: 'sent',
createdBy: 'seed',
},
{
portId: port.id,
clientId: client.id,
documentType: 'contract',
title: 'Contract X',
status: 'sent',
createdBy: 'seed',
},
]);
const counts = await getHubTabCounts(port.id, undefined);
expect(counts.eoi_queue).toBe(2);
// The contract should not bump eoi_queue.
expect(counts.all).toBe(3);
});
it('returns an empty list when no in-flight EOIs exist', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await db.insert(documents).values({
portId: port.id,
clientId: client.id,
documentType: 'eoi',
title: 'old EOI',
status: 'expired',
createdBy: 'seed',
});
const result = await listDocuments(
port.id,
{
page: 1,
limit: 50,
sort: 'createdAt',
order: 'desc',
includeArchived: false,
tab: 'eoi_queue',
},
{},
);
expect(result.data).toHaveLength(0);
const counts = await getHubTabCounts(port.id, undefined);
expect(counts.eoi_queue).toBe(0);
});
});