From f525306feb138d3446ca86ff6f0c8e3af77225f5 Mon Sep 17 00:00:00 2001 From: jeffgicharu Date: Tue, 19 May 2026 02:39:55 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20coherent=20demo=20data=20=E2=80=94?= =?UTF-8?q?=20compliance,=20audit=20trail,=20currency,=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seeded demo dataset produced several numbers that read as bugs rather than a working business. This reseeds the demo data so every screen tells one coherent story, and hardens a few queries so the views can't disagree. Compliance - Documents were assigned random types across contractors, so almost no contractor held both required documents and the dashboard showed "0% Compliance Rate". Documents are now generated as a coherent per- contractor set with a deterministic ~85% compliant / ~12% expiring / ~13% missing-one-doc distribution (stable across reseeds). - getComplianceReport is scoped to active/suspended contractors: someone still in onboarding is not "non-compliant", they're just not done. W-9 consistency - contractors.repository documentStatus now treats an expired document as not-on-file, so the Overview tab can no longer say "W-9 on file" while the Documents tab shows it "Expired". Seed data is coherent on both views (compliant = far-future expiry; expiring = inside 30 days). Audit trail - audit_events was empty except for "notifications / read". A believable ~5-month activity stream is now seeded: staff logins, the full invoice lifecycle (pinned to each invoice's own timestamps so nothing is "paid" before it was "submitted"), contractor/document/offboarding/settings/ classification events, with real actors and no future-dated rows. Currency - The full seed sets the organization's default currency to USD, matching the USD-everywhere display formatting. Invoices & charts - generateInvoice anchored the lifecycle off a small offset, so recent "paid" invoices could land a future paid_at. The chain is now built with guaranteed past headroom and a net-30 due date. - Monthly revenue / contractor-growth / contractor-earnings queries only returned months that had data, making the dashboard revenue chart taper to $0. They now zero-fill a fixed N-month spine via generate_series. Tooling - Add seed:demo-accounts script and document the two-step reseed (the full seed rebuilds every org, so the idempotent demo/E2E accounts must be re-seeded immediately after). --- .gitignore | 3 + README.md | 7 +- apps/api/package.json | 1 + .../src/database/seeds/fixtures/generators.ts | 454 +++++++++++++++++- apps/api/src/database/seeds/seed.ts | 137 +++++- .../contractors/contractors.repository.ts | 4 +- .../modules/documents/documents.repository.ts | 2 +- .../organizations/dashboard.repository.ts | 104 ++-- 8 files changed, 641 insertions(+), 71 deletions(-) diff --git a/.gitignore b/.gitignore index 14b965a..ad833bf 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ CLAUDE.md PROJECT_BRIEF.md ARCHITECTURE.md DESIGN_SYSTEM.md + +# playwright-cli local session artifacts +.playwright-cli/ diff --git a/README.md b/README.md index 20560a7..8b6847d 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,13 @@ createdb contractor_os # Run migrations (9 migration files covering all tables + indexes) pnpm --filter @contractor-os/api migrate:up -# Seed with demo data (55+ contractors, 130+ invoices, 340+ time entries) +# Seed with demo data (55+ contractors, 130+ invoices, 340+ time entries, +# a coherent ~85% document-compliance distribution, and a ~5-month audit trail) pnpm --filter @contractor-os/api seed + +# Restore the stable demo/E2E accounts (idempotent — the full seed above +# rebuilds every org, so always re-run this immediately after it) +pnpm --filter @contractor-os/api seed:demo-accounts ``` ### Run diff --git a/apps/api/package.json b/apps/api/package.json index d5ae96d..cca1a7b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "migrate:down": "node --import tsx node_modules/node-pg-migrate/bin/node-pg-migrate.js down --migrations-dir src/database/migrations", "migrate:create": "node-pg-migrate create --migrations-dir src/database/migrations --tsconfig tsconfig.json", "seed": "tsx src/database/seeds/seed.ts", + "seed:demo-accounts": "tsx scripts/seed-demo-accounts.ts", "clean": "rm -rf dist" }, "dependencies": { diff --git a/apps/api/src/database/seeds/fixtures/generators.ts b/apps/api/src/database/seeds/fixtures/generators.ts index 723417a..9b06228 100644 --- a/apps/api/src/database/seeds/fixtures/generators.ts +++ b/apps/api/src/database/seeds/fixtures/generators.ts @@ -128,19 +128,39 @@ export function generateInvoice( monthsAgo?: number, ) { const now = Date.now(); - // Spread invoices across multiple months for richer chart data - const baseOffset = monthsAgo !== undefined - ? monthsAgo * 30 + randomBetween(1, 25) - : randomBetween(10, 180); - const submitted = status !== 'draft' ? new Date(now - baseOffset * 86_400_000).toISOString() : null; - const approved = ['approved', 'scheduled', 'paid'].includes(status) - ? new Date(new Date(submitted!).getTime() + randomBetween(1, 5) * 86_400_000).toISOString() + const DAY = 86_400_000; + + // Lifecycle gaps, then anchor `submitted` far enough in the past that the + // whole chain (submit → approve → schedule → pay) always lands before now — + // an invoice must never be "paid" with a future paid_at. + const gApprove = randomBetween(1, 5); + const gSchedule = randomBetween(1, 3); + const gPay = randomBetween(1, 7); + const wantApprove = ['approved', 'scheduled', 'paid'].includes(status); + const wantSchedule = ['scheduled', 'paid'].includes(status); + const wantPaid = status === 'paid'; + const chainDays = + (wantApprove ? gApprove : 0) + + (wantSchedule ? gSchedule : 0) + + (wantPaid ? gPay : 0); + + const requestedOffset = + monthsAgo !== undefined + ? monthsAgo * 30 + randomBetween(1, 25) + : randomBetween(20, 180); + // Keep at least 2 days of headroom after the final lifecycle step. + const baseOffset = Math.max(requestedOffset, chainDays + 2); + + const submittedMs = now - baseOffset * DAY; + const submitted = status !== 'draft' ? new Date(submittedMs).toISOString() : null; + const approved = wantApprove + ? new Date(submittedMs + gApprove * DAY).toISOString() : null; - const scheduled = ['scheduled', 'paid'].includes(status) - ? new Date(new Date(approved!).getTime() + randomBetween(1, 3) * 86_400_000).toISOString() + const scheduled = wantSchedule + ? new Date(submittedMs + (gApprove + gSchedule) * DAY).toISOString() : null; - const paid = status === 'paid' - ? new Date(new Date(scheduled!).getTime() + randomBetween(1, 7) * 86_400_000).toISOString() + const paid = wantPaid + ? new Date(submittedMs + (gApprove + gSchedule + gPay) * DAY).toISOString() : null; const lineItemCount = randomBetween(1, 4); @@ -150,7 +170,11 @@ export function generateInvoice( unitPrice: randomBetween(50, 200), })); - const periodOffset = baseOffset + randomBetween(0, 30); + // Billing period sits before submission; due 30 days after submission + // (net-30), or for drafts, 15 days out from a recent period. + const periodEndMs = (submitted ? submittedMs : now) - randomBetween(2, 7) * DAY; + const periodStartMs = periodEndMs - 30 * DAY; + const dueMs = submitted ? submittedMs + 30 * DAY : now + 15 * DAY; return { id: randomUUID(), @@ -163,36 +187,416 @@ export function generateInvoice( approvedAt: approved, scheduledAt: scheduled, paidAt: paid, - dueDate: new Date(now - (baseOffset - 30) * 86_400_000).toISOString().split('T')[0]!, + dueDate: new Date(dueMs).toISOString().split('T')[0]!, notes: null, - periodStart: new Date(now - periodOffset * 86_400_000).toISOString().split('T')[0]!, - periodEnd: new Date(now - (periodOffset - 30) * 86_400_000).toISOString().split('T')[0]!, + periodStart: new Date(periodStartMs).toISOString().split('T')[0]!, + periodEnd: new Date(periodEndMs).toISOString().split('T')[0]!, lineItems, }; } -export function generateDocument(contractorId: string, orgId: string, uploadedBy: string, idx: number) { - const docType = DOCUMENT_TYPES[idx % DOCUMENT_TYPES.length]!; - const expiresInDays = idx % 5 === 0 ? randomBetween(-30, 10) : randomBetween(30, 365); +export type ComplianceBucket = 'compliant' | 'expiring' | 'noncompliant'; + +export interface SeedDocument { + id: string; + contractorId: string; + organizationId: string; + documentType: (typeof DOCUMENT_TYPES)[number]; + filePath: string; + fileName: string; + fileSizeBytes: number; + mimeType: string; + uploadedBy: string; + expiresAt: string | null; + tinLastFour: string | null; + isCurrent: boolean; + version: number; + notes: string | null; + createdAt: string; +} +function makeDoc( + contractorId: string, + orgId: string, + uploadedBy: string, + documentType: (typeof DOCUMENT_TYPES)[number], + opts: { + expiresInDays: number | null; + isCurrent?: boolean; + version?: number; + tinLastFour?: string | null; + createdDaysAgo: number; + }, +): SeedDocument { return { id: randomUUID(), contractorId, organizationId: orgId, - documentType: docType, + documentType, filePath: `${orgId}/${contractorId}/${randomUUID()}.pdf`, - fileName: `${docType}_${idx}.pdf`, - fileSizeBytes: randomBetween(10000, 500000), + fileName: `${documentType}.pdf`, + fileSizeBytes: randomBetween(48_000, 480_000), mimeType: 'application/pdf', uploadedBy, - expiresAt: docType !== 'other' ? new Date(Date.now() + expiresInDays * 86_400_000).toISOString() : null, - tinLastFour: docType === 'w9' ? String(1000 + idx).slice(-4) : null, - isCurrent: true, - version: 1, + expiresAt: + opts.expiresInDays === null + ? null + : new Date(Date.now() + opts.expiresInDays * 86_400_000).toISOString(), + tinLastFour: opts.tinLastFour ?? null, + isCurrent: opts.isCurrent ?? true, + version: opts.version ?? 1, notes: null, + createdAt: new Date(Date.now() - opts.createdDaysAgo * 86_400_000).toISOString(), }; } +/** + * Builds a coherent document set for an onboarded contractor. The bucket is + * assigned deterministically by the caller so the org-wide compliance rate is + * stable across reseeds. Every document a contractor gets is internally + * consistent: a "compliant" contractor's W-9/W-8BEN is genuinely current and + * far from expiry (so the profile tab, documents tab, and compliance report all + * agree), an "expiring" contractor's required tax form is current but inside + * the 30-day window, and a "noncompliant" contractor is missing exactly one + * required document. + */ +export function generateContractorDocuments( + contractorId: string, + orgId: string, + uploadedBy: string, + contractorType: 'domestic' | 'foreign', + bucket: ComplianceBucket, + idx: number, + tinLastFour: string | null, +): SeedDocument[] { + const taxType = contractorType === 'foreign' ? 'w8ben' : 'w9'; + const docs: SeedDocument[] = []; + const taxTin = contractorType === 'foreign' ? null : tinLastFour; + + if (bucket === 'compliant') { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, taxType, { + expiresInDays: 365 + (idx % 12) * 24, + tinLastFour: taxTin, + createdDaysAgo: 90 + (idx % 60), + }), + ); + docs.push( + makeDoc(contractorId, orgId, uploadedBy, 'contract', { + expiresInDays: null, + createdDaysAgo: 88 + (idx % 60), + }), + ); + if (idx % 2 === 0) { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, 'nda', { + expiresInDays: null, + createdDaysAgo: 80 + (idx % 40), + }), + ); + } + if (idx % 3 === 0) { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, 'insurance_certificate', { + expiresInDays: 150 + (idx % 9) * 20, + createdDaysAgo: 70 + (idx % 30), + }), + ); + } + // A superseded prior version, so version history looks real. + if (idx % 5 === 0) { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, taxType, { + expiresInDays: -120, + isCurrent: false, + version: 1, + tinLastFour: taxTin, + createdDaysAgo: 430 + (idx % 60), + }), + ); + docs[0]!.version = 2; + } + } else if (bucket === 'expiring') { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, taxType, { + expiresInDays: 6 + (idx % 21), + tinLastFour: taxTin, + createdDaysAgo: 330 + (idx % 30), + }), + ); + docs.push( + makeDoc(contractorId, orgId, uploadedBy, 'contract', { + expiresInDays: null, + createdDaysAgo: 120 + (idx % 40), + }), + ); + if (idx % 2 === 0) { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, 'insurance_certificate', { + expiresInDays: 9 + (idx % 18), + createdDaysAgo: 200 + (idx % 30), + }), + ); + } + } else { + // Missing exactly one required document — alternate which one. + if (idx % 2 === 0) { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, taxType, { + expiresInDays: 365 + (idx % 6) * 30, + tinLastFour: taxTin, + createdDaysAgo: 40 + (idx % 30), + }), + ); + if (idx % 3 === 0) { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, 'nda', { + expiresInDays: null, + createdDaysAgo: 35 + (idx % 20), + }), + ); + } + } else { + docs.push( + makeDoc(contractorId, orgId, uploadedBy, 'contract', { + expiresInDays: null, + createdDaysAgo: 30 + (idx % 25), + }), + ); + } + } + + return docs; +} + +/** + * A believable activity stream. Invoice lifecycle events are pinned to the + * invoice's own timestamps so an invoice is never "paid" before it was + * "submitted"; everything else is spread across the trailing window with + * realistic actors. + */ +export interface SeedAuditEvent { + userId: string; + entityType: string; + entityId: string; + action: string; + oldValues: Record | null; + newValues: Record | null; + ipAddress: string; + createdAt: string; +} + +interface AuditUser { + id: string; + email: string; + role: 'admin' | 'manager' | 'contractor'; +} + +interface AuditInvoiceRef { + id: string; + invoiceNumber: string; + status: string; + contractorName: string; + submittedAt: string | null; + approvedAt: string | null; + scheduledAt: string | null; + paidAt: string | null; +} + +interface AuditContractorRef { + id: string; + name: string; + createdAt: string; + status: string; +} + +const AUDIT_IPS = ['203.0.113.24', '198.51.100.7', '203.0.113.91', '198.51.100.45', '192.0.2.130']; + +function auditDay(daysAgo: number): string { + const d = new Date(Date.now() - daysAgo * 86_400_000); + d.setUTCHours(13 + randomBetween(-4, 6), randomBetween(0, 59), randomBetween(0, 59), 0); + // Never emit a future-dated event (matters for daysAgo === 0). + const ms = Math.min(d.getTime(), Date.now() - 60_000); + return new Date(ms).toISOString(); +} + +export function generateAuditEvents( + users: AuditUser[], + contractors: AuditContractorRef[], + invoices: AuditInvoiceRef[], + offboardingIds: string[], +): SeedAuditEvent[] { + const events: SeedAuditEvent[] = []; + const admin = users.find((u) => u.role === 'admin')!; + const manager = users.find((u) => u.role === 'manager')!; + const staff = [admin, manager]; + const ip = () => randomPick(AUDIT_IPS); + + // Daily-ish staff logins across the trailing 75 days. + for (let d = 75; d >= 0; d--) { + if (Math.random() < 0.28) continue; + const logins = randomBetween(1, 3); + for (let i = 0; i < logins; i++) { + const u = randomPick(staff); + events.push({ + userId: u.id, + entityType: 'auth', + entityId: u.id, + action: 'login', + oldValues: null, + newValues: { email: u.email, method: 'password' }, + ipAddress: ip(), + createdAt: auditDay(d), + }); + } + } + + // Invoice lifecycle — pinned to the invoice's own timestamps. + for (const inv of invoices) { + if (inv.submittedAt) { + events.push({ + userId: manager.id, + entityType: 'invoices', + entityId: inv.id, + action: 'submit', + oldValues: { status: 'draft' }, + newValues: { status: 'submitted', invoiceNumber: inv.invoiceNumber, contractor: inv.contractorName }, + ipAddress: ip(), + createdAt: inv.submittedAt, + }); + } + if (inv.approvedAt) { + events.push({ + userId: admin.id, + entityType: 'invoices', + entityId: inv.id, + action: 'approve', + oldValues: { status: 'submitted' }, + newValues: { status: 'approved', invoiceNumber: inv.invoiceNumber }, + ipAddress: ip(), + createdAt: inv.approvedAt, + }); + } + if (inv.scheduledAt) { + events.push({ + userId: admin.id, + entityType: 'invoices', + entityId: inv.id, + action: 'schedule', + oldValues: { status: 'approved' }, + newValues: { status: 'scheduled', invoiceNumber: inv.invoiceNumber }, + ipAddress: ip(), + createdAt: inv.scheduledAt, + }); + } + if (inv.paidAt) { + events.push({ + userId: admin.id, + entityType: 'invoices', + entityId: inv.id, + action: 'mark-paid', + oldValues: { status: 'scheduled' }, + newValues: { status: 'paid', invoiceNumber: inv.invoiceNumber }, + ipAddress: ip(), + createdAt: inv.paidAt, + }); + } + } + + // Contractor lifecycle changes, never dated before the contractor existed. + for (let i = 0; i < contractors.length; i += 2) { + const c = contractors[i]!; + const createdDaysAgo = Math.max( + 1, + Math.floor((Date.now() - new Date(c.createdAt).getTime()) / 86_400_000), + ); + if (createdDaysAgo < 2) continue; + const eventDaysAgo = randomBetween(1, Math.min(createdDaysAgo - 1, 70)); + const u = randomPick(staff); + if (c.status === 'suspended') { + events.push({ + userId: u.id, + entityType: 'contractors', + entityId: c.id, + action: 'update', + oldValues: { status: 'active' }, + newValues: { status: 'suspended', reason: 'Compliance review' }, + ipAddress: ip(), + createdAt: auditDay(eventDaysAgo), + }); + } else { + events.push({ + userId: u.id, + entityType: 'contractors', + entityId: c.id, + action: 'update', + oldValues: { phone: null }, + newValues: { phone: '+1-555-0100', city: 'Austin' }, + ipAddress: ip(), + createdAt: auditDay(eventDaysAgo), + }); + } + } + + // Document uploads. + for (let i = 0; i < contractors.length; i += 3) { + const c = contractors[i]!; + events.push({ + userId: randomPick(staff).id, + entityType: 'documents', + entityId: c.id, + action: 'create', + oldValues: null, + newValues: { type: randomPick(['w9', 'contract', 'insurance_certificate', 'nda']), contractor: c.name }, + ipAddress: ip(), + createdAt: auditDay(randomBetween(1, 70)), + }); + } + + // Offboarding initiations. + for (const wfId of offboardingIds) { + events.push({ + userId: admin.id, + entityType: 'offboarding', + entityId: wfId, + action: 'create', + oldValues: null, + newValues: { reason: randomPick(['project_completed', 'mutual_agreement', 'budget_cut']) }, + ipAddress: ip(), + createdAt: auditDay(randomBetween(1, 55)), + }); + } + + // Organization settings updates (rich diff for the diff viewer). + events.push({ + userId: admin.id, + entityType: 'organizations', + entityId: admin.id, + action: 'update', + oldValues: { settings: { defaultPaymentTerms: 'net_15', defaultCurrency: 'USD', reminderDays: [7, 1] } }, + newValues: { settings: { defaultPaymentTerms: 'net_30', defaultCurrency: 'USD', reminderDays: [7, 3, 1] } }, + ipAddress: ip(), + createdAt: auditDay(randomBetween(40, 70)), + }); + + // Classification re-runs. + for (let i = 0; i < Math.min(8, contractors.length); i++) { + const c = contractors[i * 4 % contractors.length]!; + events.push({ + userId: admin.id, + entityType: 'classification', + entityId: c.id, + action: 'create', + oldValues: null, + newValues: { overallRisk: randomPick(['low', 'medium', 'high']), contractor: c.name }, + ipAddress: ip(), + createdAt: auditDay(randomBetween(1, 60)), + }); + } + + return events.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + export function generateNotification( userId: string, type: string, diff --git a/apps/api/src/database/seeds/seed.ts b/apps/api/src/database/seeds/seed.ts index 0cc3650..312829f 100644 --- a/apps/api/src/database/seeds/seed.ts +++ b/apps/api/src/database/seeds/seed.ts @@ -9,12 +9,14 @@ import { generateEngagement, generateTimeEntries, generateInvoice, - generateDocument, + generateContractorDocuments, + generateAuditEvents, generateNotification, randomPick, randomBetween, randomDateOnly, } from './fixtures/generators'; +import type { ComplianceBucket } from './fixtures/generators'; const BCRYPT_ROUNDS = 12; const CONTRACTOR_COUNT = 55; @@ -84,6 +86,15 @@ async function demoSeed() { generateContractor(SEED_ORG_ID, i), ); const activeContractorIds: string[] = []; + interface OnboardedContractor { + id: string; + type: 'domestic' | 'foreign'; + tinLastFour: string | null; + name: string; + createdAt: string; + status: string; + } + const onboardedContractors: OnboardedContractor[] = []; for (let ci = 0; ci < contractors.length; ci++) { const c = contractors[ci]!; @@ -133,6 +144,14 @@ async function demoSeed() { if (c.status === 'active' || c.status === 'suspended') { activeContractorIds.push(c.id); + onboardedContractors.push({ + id: c.id, + type: c.type, + tinLastFour: c.tinLastFour ?? null, + name: `${c.firstName} ${c.lastName}`, + createdAt, + status: c.status, + }); } } // Link first contractor to user account @@ -177,6 +196,19 @@ async function demoSeed() { console.log(`Inserted ${timeEntryCount} time entries`); // Invoices — spread across last 6 months for rich chart data + const contractorNameById = new Map( + onboardedContractors.map((c) => [c.id, c.name] as const), + ); + const invoiceRefs: Array<{ + id: string; + invoiceNumber: string; + status: string; + contractorName: string; + submittedAt: string | null; + approvedAt: string | null; + scheduledAt: string | null; + paidAt: string | null; + }> = []; let invoiceNum = 0; let invoiceCount = 0; const PAID_MONTHS = [0, 0, 0, 1, 1, 1, 2, 2, 3, 3, 4, 5]; // weighted toward recent months @@ -216,28 +248,75 @@ async function demoSeed() { [inv.id], ); + invoiceRefs.push({ + id: inv.id, + invoiceNumber: inv.invoiceNumber, + status: inv.status, + contractorName: contractorNameById.get(eng.contractorId) ?? 'Contractor', + submittedAt: inv.submittedAt, + approvedAt: inv.approvedAt, + scheduledAt: inv.scheduledAt, + paidAt: inv.paidAt, + }); + invoiceCount++; } } console.log(`Inserted ${invoiceCount} invoices`); - // Documents + // Documents — coherent compliance distribution across onboarded contractors. + // ~70% fully compliant, ~15% compliant-but-expiring (still compliant, drives + // the "expiring soon" alerts), ~15% missing exactly one required document. + // The bucket is deterministic by index so the org-wide compliance rate is + // stable across reseeds (~85%). + function bucketForIndex(i: number): ComplianceBucket { + const m = i % 7; + if (m === 6) return 'noncompliant'; + if (m === 5) return 'expiring'; + return 'compliant'; + } let docCount = 0; - for (const cId of activeContractorIds.slice(0, 40)) { - const count = randomBetween(1, 3); - for (let i = 0; i < count; i++) { - const doc = generateDocument(cId, SEED_ORG_ID, SEED_ADMIN_ID, docCount); + const complianceTally: Record = { + compliant: 0, + expiring: 0, + noncompliant: 0, + }; + for (let oi = 0; oi < onboardedContractors.length; oi++) { + const oc = onboardedContractors[oi]!; + const bucket = bucketForIndex(oi); + complianceTally[bucket]++; + const docs = generateContractorDocuments( + oc.id, + SEED_ORG_ID, + SEED_ADMIN_ID, + oc.type, + bucket, + oi, + oc.tinLastFour, + ); + for (const doc of docs) { await pool.query( `INSERT INTO tax_documents (id, contractor_id, organization_id, document_type, file_path, file_name, - file_size_bytes, mime_type, uploaded_by, expires_at, tin_last_four, is_current, version, notes) - VALUES ($1,$2,$3,$4::tax_document_type,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + file_size_bytes, mime_type, uploaded_by, expires_at, tin_last_four, is_current, version, notes, created_at) + VALUES ($1,$2,$3,$4::tax_document_type,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, [doc.id, doc.contractorId, doc.organizationId, doc.documentType, doc.filePath, doc.fileName, - doc.fileSizeBytes, doc.mimeType, doc.uploadedBy, doc.expiresAt, doc.tinLastFour, doc.isCurrent, doc.version, doc.notes], + doc.fileSizeBytes, doc.mimeType, doc.uploadedBy, doc.expiresAt, doc.tinLastFour, doc.isCurrent, doc.version, doc.notes, doc.createdAt], ); docCount++; } } - console.log(`Inserted ${docCount} documents`); + const complianceRate = onboardedContractors.length + ? Math.round( + ((complianceTally.compliant + complianceTally.expiring) / + onboardedContractors.length) * + 100, + ) + : 0; + console.log( + `Inserted ${docCount} documents across ${onboardedContractors.length} onboarded contractors ` + + `(compliant ${complianceTally.compliant}, expiring ${complianceTally.expiring}, ` + + `non-compliant ${complianceTally.noncompliant} → ~${complianceRate}% compliance rate)`, + ); // Classification assessments let assessmentCount = 0; @@ -294,8 +373,10 @@ async function demoSeed() { const offboardReasons = ['project_completed', 'budget_cut', 'performance', 'mutual_agreement', 'compliance_risk'] as const; const offboardStatuses = ['initiated', 'in_progress', 'pending_final_invoice', 'completed', 'cancelled'] as const; let offboardCount = 0; + const offboardingIds: string[] = []; for (const cId of activeContractorIds.slice(0, 5)) { const workflowId = randomUUID(); + offboardingIds.push(workflowId); const status = offboardStatuses[offboardCount % offboardStatuses.length]!; await pool.query( `INSERT INTO offboarding_workflows (id, contractor_id, organization_id, initiated_by, reason, effective_date, status, notes) @@ -348,6 +429,42 @@ async function demoSeed() { } console.log(`Inserted ${notifData.length} notifications`); + // Audit events — a believable activity stream over the trailing ~75 days. + const auditEvents = generateAuditEvents( + [ + { id: SEED_ADMIN_ID, email: 'admin@acme-corp.com', role: 'admin' }, + { id: SEED_MANAGER_ID, email: 'manager@acme-corp.com', role: 'manager' }, + { id: SEED_CONTRACTOR_USER_ID, email: 'john.smith@example.com', role: 'contractor' }, + ], + onboardedContractors.map((c) => ({ + id: c.id, + name: c.name, + createdAt: c.createdAt, + status: c.status, + })), + invoiceRefs, + offboardingIds, + ); + for (const ev of auditEvents) { + await pool.query( + `INSERT INTO audit_events + (organization_id, user_id, entity_type, entity_id, action, old_values, new_values, ip_address, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8::inet,$9)`, + [ + SEED_ORG_ID, + ev.userId, + ev.entityType, + ev.entityId, + ev.action, + ev.oldValues ? JSON.stringify(ev.oldValues) : null, + ev.newValues ? JSON.stringify(ev.newValues) : null, + ev.ipAddress, + ev.createdAt, + ], + ); + } + console.log(`Inserted ${auditEvents.length} audit events`); + // Refresh materialized view await pool.query('REFRESH MATERIALIZED VIEW mv_classification_risk_summary'); console.log('Refreshed classification risk summary materialized view'); diff --git a/apps/api/src/modules/contractors/contractors.repository.ts b/apps/api/src/modules/contractors/contractors.repository.ts index d7a5c83..c81b2d8 100644 --- a/apps/api/src/modules/contractors/contractors.repository.ts +++ b/apps/api/src/modules/contractors/contractors.repository.ts @@ -192,8 +192,8 @@ export class ContractorsRepository { expiring: string; }>( `SELECT - EXISTS(SELECT 1 FROM tax_documents WHERE contractor_id = $1 AND document_type = 'w9' AND is_current = true) as has_w9, - EXISTS(SELECT 1 FROM tax_documents WHERE contractor_id = $1 AND document_type = 'contract' AND is_current = true) as has_contract, + EXISTS(SELECT 1 FROM tax_documents WHERE contractor_id = $1 AND document_type = 'w9' AND is_current = true AND (expires_at IS NULL OR expires_at > now())) as has_w9, + EXISTS(SELECT 1 FROM tax_documents WHERE contractor_id = $1 AND document_type = 'contract' AND is_current = true AND (expires_at IS NULL OR expires_at > now())) as has_contract, COUNT(*) FILTER (WHERE expires_at IS NOT NULL AND expires_at < now() + INTERVAL '30 days') as expiring FROM tax_documents WHERE contractor_id = $1 AND is_current = true`, [id], diff --git a/apps/api/src/modules/documents/documents.repository.ts b/apps/api/src/modules/documents/documents.repository.ts index a6d8cf8..0c61628 100644 --- a/apps/api/src/modules/documents/documents.repository.ts +++ b/apps/api/src/modules/documents/documents.repository.ts @@ -202,7 +202,7 @@ export class DocumentsRepository { STRING_AGG(DISTINCT CASE WHEN d.is_current = true AND d.expires_at IS NOT NULL AND d.expires_at <= NOW() + INTERVAL '30 days' THEN d.document_type::text || ':' || d.expires_at::text END, ',') as expiring_dates FROM contractors c LEFT JOIN tax_documents d ON d.contractor_id = c.id AND d.organization_id = $1 - WHERE c.organization_id = $1 AND c.status != 'offboarded' + WHERE c.organization_id = $1 AND c.status IN ('active', 'suspended') GROUP BY c.id, c.first_name, c.last_name, c.type ORDER BY c.last_name, c.first_name`, [orgId], diff --git a/apps/api/src/modules/organizations/dashboard.repository.ts b/apps/api/src/modules/organizations/dashboard.repository.ts index 2eb06e2..8083887 100644 --- a/apps/api/src/modules/organizations/dashboard.repository.ts +++ b/apps/api/src/modules/organizations/dashboard.repository.ts @@ -36,18 +36,32 @@ export class DashboardRepository { total: string; count: string; }>( - `SELECT - to_char(date_trunc('month', i.paid_at), 'YYYY-MM') AS month, - to_char(date_trunc('month', i.paid_at), 'Mon') AS month_label, - COALESCE(SUM(i.total_amount), 0) AS total, - COUNT(*)::text AS count - FROM invoices i - JOIN contractors c ON i.contractor_id = c.id - WHERE c.organization_id = $1 - AND i.status = 'paid' - AND i.paid_at >= date_trunc('month', now()) - interval '${months - 1} months' - GROUP BY date_trunc('month', i.paid_at) - ORDER BY month ASC`, + `WITH spine AS ( + SELECT generate_series( + date_trunc('month', now()) - interval '${months - 1} months', + date_trunc('month', now()), + interval '1 month' + ) AS month + ), + rev AS ( + SELECT date_trunc('month', i.paid_at) AS month, + SUM(i.total_amount) AS total, + COUNT(*) AS count + FROM invoices i + JOIN contractors c ON i.contractor_id = c.id + WHERE c.organization_id = $1 + AND i.status = 'paid' + AND i.paid_at >= date_trunc('month', now()) - interval '${months - 1} months' + GROUP BY 1 + ) + SELECT + to_char(s.month, 'YYYY-MM') AS month, + to_char(s.month, 'Mon') AS month_label, + COALESCE(r.total, 0) AS total, + COALESCE(r.count, 0)::text AS count + FROM spine s + LEFT JOIN rev r ON r.month = s.month + ORDER BY s.month ASC`, [orgId], ); @@ -98,15 +112,27 @@ export class DashboardRepository { month_label: string; total: string; }>( - `SELECT - to_char(date_trunc('month', created_at), 'YYYY-MM') AS month, - to_char(date_trunc('month', created_at), 'Mon') AS month_label, - COUNT(*)::text AS total - FROM contractors - WHERE organization_id = $1 - AND created_at >= date_trunc('month', now()) - interval '${months - 1} months' - GROUP BY date_trunc('month', created_at) - ORDER BY month ASC`, + `WITH spine AS ( + SELECT generate_series( + date_trunc('month', now()) - interval '${months - 1} months', + date_trunc('month', now()), + interval '1 month' + ) AS month + ), + growth AS ( + SELECT date_trunc('month', created_at) AS month, COUNT(*) AS total + FROM contractors + WHERE organization_id = $1 + AND created_at >= date_trunc('month', now()) - interval '${months - 1} months' + GROUP BY 1 + ) + SELECT + to_char(s.month, 'YYYY-MM') AS month, + to_char(s.month, 'Mon') AS month_label, + COALESCE(g.total, 0)::text AS total + FROM spine s + LEFT JOIN growth g ON g.month = s.month + ORDER BY s.month ASC`, [orgId], ); @@ -135,17 +161,31 @@ export class DashboardRepository { total: string; count: string; }>( - `SELECT - to_char(date_trunc('month', i.paid_at), 'YYYY-MM') AS month, - to_char(date_trunc('month', i.paid_at), 'Mon') AS month_label, - COALESCE(SUM(i.total_amount), 0) AS total, - COUNT(*)::text AS count - FROM invoices i - WHERE i.contractor_id = $1 - AND i.status = 'paid' - AND i.paid_at >= date_trunc('month', now()) - interval '${months - 1} months' - GROUP BY date_trunc('month', i.paid_at) - ORDER BY month ASC`, + `WITH spine AS ( + SELECT generate_series( + date_trunc('month', now()) - interval '${months - 1} months', + date_trunc('month', now()), + interval '1 month' + ) AS month + ), + rev AS ( + SELECT date_trunc('month', i.paid_at) AS month, + SUM(i.total_amount) AS total, + COUNT(*) AS count + FROM invoices i + WHERE i.contractor_id = $1 + AND i.status = 'paid' + AND i.paid_at >= date_trunc('month', now()) - interval '${months - 1} months' + GROUP BY 1 + ) + SELECT + to_char(s.month, 'YYYY-MM') AS month, + to_char(s.month, 'Mon') AS month_label, + COALESCE(r.total, 0) AS total, + COALESCE(r.count, 0)::text AS count + FROM spine s + LEFT JOIN rev r ON r.month = s.month + ORDER BY s.month ASC`, [contractorId], ); From d9e50ed22df93cae0eeba40f1876e6819b82ffb3 Mon Sep 17 00:00:00 2001 From: jeffgicharu Date: Tue, 19 May 2026 02:48:32 +0300 Subject: [PATCH 2/2] fix(seed): use crypto.randomInt for fixture randomness CodeQL flagged the new seed code's Math.random() as insecure randomness. It's seed-only fixture data, but route all fixture randomness through crypto.randomInt (via the existing randomPick/randomBetween helpers and a new randomBool) so static analysis is clean and the alert can't recur. --- .../src/database/seeds/fixtures/generators.ts | 18 ++++++++++++------ apps/api/src/database/seeds/seed.ts | 7 ++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/api/src/database/seeds/fixtures/generators.ts b/apps/api/src/database/seeds/fixtures/generators.ts index 9b06228..a43872a 100644 --- a/apps/api/src/database/seeds/fixtures/generators.ts +++ b/apps/api/src/database/seeds/fixtures/generators.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'crypto'; +import { randomInt, randomUUID } from 'crypto'; const FIRST_NAMES = [ 'James', 'Maria', 'Robert', 'Jennifer', 'Michael', 'Linda', 'David', 'Patricia', @@ -44,18 +44,24 @@ const ENGAGEMENT_TITLES = [ const DOCUMENT_TYPES = ['w9', 'w8ben', 'insurance_certificate', 'nda', 'contract', 'other'] as const; +// Seed data only — crypto.randomInt keeps static analysis happy (no +// Math.random in a flagged context) and is plenty for fixture generation. export function randomPick(arr: readonly T[]): T { - return arr[Math.floor(Math.random() * arr.length)]!; + return arr[randomInt(arr.length)]!; } export function randomBetween(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; + return randomInt(min, max + 1); +} + +export function randomBool(probabilityTrue = 0.5): boolean { + return randomInt(10_000) < Math.round(probabilityTrue * 10_000); } export function randomDate(daysAgoStart: number, daysAgoEnd: number): string { const start = Date.now() - daysAgoStart * 86_400_000; const end = Date.now() - daysAgoEnd * 86_400_000; - return new Date(start + Math.random() * (end - start)).toISOString(); + return new Date(start + randomInt(Math.max(1, end - start))).toISOString(); } export function randomDateOnly(daysAgoStart: number, daysAgoEnd: number): string { @@ -114,7 +120,7 @@ export function generateTimeEntries(contractorId: string, engagementId: string, contractorId, engagementId, entryDate: randomDateOnly(90, 1), - hours: randomBetween(1, 8) + (Math.random() > 0.5 ? 0.5 : 0), + hours: randomBetween(1, 8) + (randomBool() ? 0.5 : 0), description: `Work item ${i + 1}`, })); } @@ -434,7 +440,7 @@ export function generateAuditEvents( // Daily-ish staff logins across the trailing 75 days. for (let d = 75; d >= 0; d--) { - if (Math.random() < 0.28) continue; + if (randomBool(0.28)) continue; const logins = randomBetween(1, 3); for (let i = 0; i < logins; i++) { const u = randomPick(staff); diff --git a/apps/api/src/database/seeds/seed.ts b/apps/api/src/database/seeds/seed.ts index 312829f..82a0b23 100644 --- a/apps/api/src/database/seeds/seed.ts +++ b/apps/api/src/database/seeds/seed.ts @@ -14,6 +14,7 @@ import { generateNotification, randomPick, randomBetween, + randomBool, randomDateOnly, } from './fixtures/generators'; import type { ComplianceBucket } from './fixtures/generators'; @@ -332,7 +333,7 @@ async function demoSeed() { randomUUID(), cId, SEED_ORG_ID, riskLevel, score, randomBetween(10, 80), JSON.stringify({ behavioral_control: { score: randomBetween(5, 30), max: 40, factors: {} }, financial_control: { score: randomBetween(5, 20), max: 30, factors: {} }, relationship_type: { score: randomBetween(5, 20), max: 30, factors: {} } }), randomBetween(10, 80), JSON.stringify({}), - randomBetween(10, 80), JSON.stringify({ prong_a: { passed: Math.random() > 0.5, weight: 34, score: randomBetween(0, 34) }, prong_b: { passed: Math.random() > 0.5, weight: 33, score: randomBetween(0, 33) }, prong_c: { passed: Math.random() > 0.5, weight: 33, score: randomBetween(0, 33) } }), + randomBetween(10, 80), JSON.stringify({ prong_a: { passed: randomBool(), weight: 34, score: randomBetween(0, 34) }, prong_b: { passed: randomBool(), weight: 33, score: randomBetween(0, 33) }, prong_c: { passed: randomBool(), weight: 33, score: randomBetween(0, 33) } }), JSON.stringify({ hoursPerWeek: randomBetween(10, 50) }), ], ); @@ -349,7 +350,7 @@ async function demoSeed() { await pool.query( `INSERT INTO classification_factors (contractor_id, category, boolean_value, period_start, period_end, source) VALUES ($1, $2::factor_category, $3, $4, $5, $6::factor_source)`, - [cId, randomPick(factorCategories), Math.random() > 0.5, '2025-01-01', '2025-12-31', 'manual'], + [cId, randomPick(factorCategories), randomBool(), '2025-01-01', '2025-12-31', 'manual'], ); factorCount++; } @@ -391,7 +392,7 @@ async function demoSeed() { await pool.query( `INSERT INTO offboarding_checklist_items (workflow_id, item_type, status) VALUES ($1,$2::checklist_item_type,$3::checklist_status)`, - [workflowId, itemType, status === 'completed' ? 'completed' : (Math.random() > 0.6 ? 'completed' : 'pending')], + [workflowId, itemType, status === 'completed' ? 'completed' : (randomBool(0.4) ? 'completed' : 'pending')], ); } offboardCount++;