feat(audit-cleanup): finish all 15 outstanding items from verified backlog
Audit cleanup completion plan, all tiers shipped:
Tier 1 (security + data integrity)
- A.7 RTBF true wipe: redact email_messages body/subject/addresses for
threads owned by deleted client; redact document_sends.recipient_email;
collect file storage keys + delete blobs post-commit.
- A.8 user_permission_overrides FK: documented inline why cascade is
correct (not set-null as audit suggested) — overrides have no value
without their user.
- W2.14 PII redaction: camelCase normalization in audit.ts +
error-events.service.ts isSensitiveKey; added city/postal/country/
birth fragments. firstName/lastName/dateOfBirth/postalCode etc. now
caught in BOTH masker paths. 12 new test cases lock the coverage.
Tier 2 (Documenso completion + refactor)
- C.2: documentEvents.recipient_email column + partial unique index for
per-recipient webhook dedup (migration 0075). handleDocumentSigned
now sets recipient_email on insert.
- Phase 2: completion_cc_emails distribution. handleDocumentCompleted
reads documents.completionCcEmails, filters out signer-duplicates
case-insensitively, fans signed PDF out to non-signer recipients.
- C.4: extracted createPublicInterest() service from the 346-line
api/public/interests route. Route becomes a thin shell (rate-limit,
port resolution, audit log, email fan-out). The trio creation logic
is now unit-testable without an HTTP fixture.
- Phase 4: POST /api/v1/document-templates/[id]/detect-fields wired
to document-field-detector.detectFields(). Sparkles "Auto-detect"
button added to template-editor.tsx — maps DetectedField → marker
with best-guess merge token (DATE / NAME / EMAIL); user retags.
Tier 3 (reporting + recommender snapshot lockfiles)
- W7.reports: extracted rollupStageRevenue / rollupStageCounts /
computeTotalForecast / computeOccupancyRate / rollupBerthStatusCounts
into src/lib/services/report-math.ts (pure functions). 16 new tests
including an inline-snapshot lockfile on a representative 7-stage
forecast. report-generators.ts now delegates.
- W7.recommender: 18 new toMatchSnapshot tripwires on classifyTier
boundaries + computeHeat at canonical input points.
Tier 4 (rolling)
- W6.attach: fixed outdated CLAUDE.md claim — threshold banner is
informational and never depended on IMAP; bounce monitoring (the
IMAP poller) is separate.
- D.1 + D.2: documented deferral inline with full why-not-build-it
reasoning so a future engineer sees the rationale.
- G.1: representative formatDate sweep (audit-log-list, user-list,
document-templates merge tokens, document-signing email). Rest of
the ~100 sites stay rolling.
Quality gates: 1420/1420 vitest (46 new tests above baseline of 1374),
tsc clean, 0 lint errors.
Plan: docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md
Migration: 0075_c2_document_events_recipient_email.sql (applied to dev DB).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:22:36 +02:00
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
import {
|
|
|
|
|
rollupStageRevenue,
|
|
|
|
|
rollupStageCounts,
|
|
|
|
|
computeTotalForecast,
|
|
|
|
|
computeOccupancyRate,
|
|
|
|
|
rollupBerthStatusCounts,
|
|
|
|
|
} from '@/lib/services/report-math';
|
|
|
|
|
|
|
|
|
|
// Canonical 7-stage pipeline (see PIPELINE_STAGES in src/lib/constants.ts):
|
|
|
|
|
// enquiry, qualified, nurturing, eoi, reservation, deposit_paid, contract.
|
|
|
|
|
// Non-canonical input stage strings go through canonicalizeStage which
|
|
|
|
|
// maps legacy 9-stage values back into the canonical bucket.
|
|
|
|
|
|
|
|
|
|
describe('rollupStageRevenue', () => {
|
|
|
|
|
it('sums revenue per canonicalized stage', () => {
|
|
|
|
|
const out = rollupStageRevenue([
|
|
|
|
|
{ stage: 'enquiry', revenue: '100.50' },
|
|
|
|
|
{ stage: 'eoi', revenue: '200.00' },
|
|
|
|
|
{ stage: 'enquiry', revenue: '50.25' },
|
|
|
|
|
]);
|
|
|
|
|
expect(out['enquiry']).toBe('150.75');
|
|
|
|
|
expect(out['eoi']).toBe('200');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('canonicalizes legacy 9-stage keys into modern buckets without dropping rows', () => {
|
|
|
|
|
// 'deposit_10pct' → 'deposit_paid', 'contract_sent' → 'contract'.
|
|
|
|
|
const out = rollupStageRevenue([
|
|
|
|
|
{ stage: 'deposit_10pct', revenue: '1000' },
|
|
|
|
|
{ stage: 'contract_sent', revenue: '2000' },
|
|
|
|
|
]);
|
|
|
|
|
expect(out['deposit_paid']).toBe('1000');
|
|
|
|
|
expect(out['contract']).toBe('2000');
|
|
|
|
|
const total = Object.values(out).reduce((a, v) => a + parseFloat(v), 0);
|
|
|
|
|
expect(total).toBe(3000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("'open' legacy alias canonicalizes to 'enquiry'", () => {
|
|
|
|
|
const out = rollupStageRevenue([
|
|
|
|
|
{ stage: 'open', revenue: '500' },
|
|
|
|
|
{ stage: 'enquiry', revenue: '100' },
|
|
|
|
|
]);
|
|
|
|
|
// Both rows land in 'enquiry' since 'open' is the legacy alias.
|
|
|
|
|
expect(out['enquiry']).toBe('600');
|
|
|
|
|
expect(out['open']).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('treats null revenue as 0', () => {
|
|
|
|
|
const out = rollupStageRevenue([
|
|
|
|
|
{ stage: 'enquiry', revenue: null },
|
|
|
|
|
{ stage: 'enquiry', revenue: '500' },
|
|
|
|
|
]);
|
|
|
|
|
expect(out['enquiry']).toBe('500');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('rollupStageCounts', () => {
|
|
|
|
|
it('sums counts per stage with canonicalization', () => {
|
|
|
|
|
const out = rollupStageCounts([
|
|
|
|
|
{ stage: 'enquiry', count: 5 },
|
|
|
|
|
{ stage: 'eoi', count: 3 },
|
|
|
|
|
{ stage: 'enquiry', count: 2 },
|
|
|
|
|
]);
|
|
|
|
|
expect(out['enquiry']).toBe(7);
|
|
|
|
|
expect(out['eoi']).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns empty object for empty input', () => {
|
|
|
|
|
expect(rollupStageCounts([])).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('computeTotalForecast', () => {
|
|
|
|
|
it('applies stage weights and returns 2-decimal-fixed string', () => {
|
|
|
|
|
const forecast = computeTotalForecast(
|
|
|
|
|
[
|
|
|
|
|
{ stage: 'enquiry', revenue: '1000' },
|
|
|
|
|
{ stage: 'eoi', revenue: '2000' },
|
|
|
|
|
],
|
|
|
|
|
{ enquiry: 0.2, eoi: 0.5 },
|
|
|
|
|
);
|
|
|
|
|
// 1000*0.2 + 2000*0.5 = 200 + 1000 = 1200.00
|
|
|
|
|
expect(forecast).toBe('1200.00');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('treats stages missing from weight map as 0 (no silent default)', () => {
|
|
|
|
|
const forecast = computeTotalForecast(
|
|
|
|
|
[
|
|
|
|
|
{ stage: 'enquiry', revenue: '1000' },
|
|
|
|
|
{ stage: 'unknown_stage', revenue: '2000' },
|
|
|
|
|
],
|
|
|
|
|
{ enquiry: 0.3 },
|
|
|
|
|
);
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
// 'unknown_stage' canonicalizes to 'enquiry' (fallback) - so it ALSO
|
feat(audit-cleanup): finish all 15 outstanding items from verified backlog
Audit cleanup completion plan, all tiers shipped:
Tier 1 (security + data integrity)
- A.7 RTBF true wipe: redact email_messages body/subject/addresses for
threads owned by deleted client; redact document_sends.recipient_email;
collect file storage keys + delete blobs post-commit.
- A.8 user_permission_overrides FK: documented inline why cascade is
correct (not set-null as audit suggested) — overrides have no value
without their user.
- W2.14 PII redaction: camelCase normalization in audit.ts +
error-events.service.ts isSensitiveKey; added city/postal/country/
birth fragments. firstName/lastName/dateOfBirth/postalCode etc. now
caught in BOTH masker paths. 12 new test cases lock the coverage.
Tier 2 (Documenso completion + refactor)
- C.2: documentEvents.recipient_email column + partial unique index for
per-recipient webhook dedup (migration 0075). handleDocumentSigned
now sets recipient_email on insert.
- Phase 2: completion_cc_emails distribution. handleDocumentCompleted
reads documents.completionCcEmails, filters out signer-duplicates
case-insensitively, fans signed PDF out to non-signer recipients.
- C.4: extracted createPublicInterest() service from the 346-line
api/public/interests route. Route becomes a thin shell (rate-limit,
port resolution, audit log, email fan-out). The trio creation logic
is now unit-testable without an HTTP fixture.
- Phase 4: POST /api/v1/document-templates/[id]/detect-fields wired
to document-field-detector.detectFields(). Sparkles "Auto-detect"
button added to template-editor.tsx — maps DetectedField → marker
with best-guess merge token (DATE / NAME / EMAIL); user retags.
Tier 3 (reporting + recommender snapshot lockfiles)
- W7.reports: extracted rollupStageRevenue / rollupStageCounts /
computeTotalForecast / computeOccupancyRate / rollupBerthStatusCounts
into src/lib/services/report-math.ts (pure functions). 16 new tests
including an inline-snapshot lockfile on a representative 7-stage
forecast. report-generators.ts now delegates.
- W7.recommender: 18 new toMatchSnapshot tripwires on classifyTier
boundaries + computeHeat at canonical input points.
Tier 4 (rolling)
- W6.attach: fixed outdated CLAUDE.md claim — threshold banner is
informational and never depended on IMAP; bounce monitoring (the
IMAP poller) is separate.
- D.1 + D.2: documented deferral inline with full why-not-build-it
reasoning so a future engineer sees the rationale.
- G.1: representative formatDate sweep (audit-log-list, user-list,
document-templates merge tokens, document-signing email). Rest of
the ~100 sites stay rolling.
Quality gates: 1420/1420 vitest (46 new tests above baseline of 1374),
tsc clean, 0 lint errors.
Plan: docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md
Migration: 0075_c2_document_events_recipient_email.sql (applied to dev DB).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:22:36 +02:00
|
|
|
// gets the enquiry weight. Verifies canonicalization stays consistent
|
|
|
|
|
// between rollup and forecast so the totals reconcile.
|
|
|
|
|
// 1000*0.3 + 2000*0.3 = 300 + 600 = 900
|
|
|
|
|
expect(forecast).toBe('900.00');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns "0.00" for empty input', () => {
|
|
|
|
|
expect(computeTotalForecast([], {})).toBe('0.00');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('skips rows with null revenue', () => {
|
|
|
|
|
const forecast = computeTotalForecast(
|
|
|
|
|
[
|
|
|
|
|
{ stage: 'enquiry', revenue: null },
|
|
|
|
|
{ stage: 'enquiry', revenue: '500' },
|
|
|
|
|
],
|
|
|
|
|
{ enquiry: 1.0 },
|
|
|
|
|
);
|
|
|
|
|
expect(forecast).toBe('500.00');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('matches expected forecast snapshot for a representative pipeline', () => {
|
|
|
|
|
// Deterministic fixture across all 7 canonical stages. Locks the
|
|
|
|
|
// math against weight-tuning regressions.
|
|
|
|
|
const rows = [
|
|
|
|
|
{ stage: 'enquiry', revenue: '50000' }, // 50000 * 0.05 = 2500
|
|
|
|
|
{ stage: 'qualified', revenue: '40000' }, // 40000 * 0.10 = 4000
|
|
|
|
|
{ stage: 'nurturing', revenue: '30000' }, // 30000 * 0.15 = 4500
|
|
|
|
|
{ stage: 'eoi', revenue: '120000' }, // 120000 * 0.25 = 30000
|
|
|
|
|
{ stage: 'reservation', revenue: '80000' }, // 80000 * 0.50 = 40000
|
|
|
|
|
{ stage: 'deposit_paid', revenue: '60000' }, // 60000 * 0.75 = 45000
|
|
|
|
|
{ stage: 'contract', revenue: '40000' }, // 40000 * 0.90 = 36000
|
|
|
|
|
];
|
|
|
|
|
const weights = {
|
|
|
|
|
enquiry: 0.05,
|
|
|
|
|
qualified: 0.1,
|
|
|
|
|
nurturing: 0.15,
|
|
|
|
|
eoi: 0.25,
|
|
|
|
|
reservation: 0.5,
|
|
|
|
|
deposit_paid: 0.75,
|
|
|
|
|
contract: 0.9,
|
|
|
|
|
};
|
|
|
|
|
// 2500 + 4000 + 4500 + 30000 + 40000 + 45000 + 36000 = 162000
|
|
|
|
|
expect(computeTotalForecast(rows, weights)).toMatchInlineSnapshot('"162000.00"');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('computeOccupancyRate', () => {
|
|
|
|
|
it('counts only "sold" as occupied (under_offer is a hold, not occupied)', () => {
|
|
|
|
|
const result = computeOccupancyRate({
|
|
|
|
|
sold: 30,
|
|
|
|
|
under_offer: 10,
|
|
|
|
|
available: 60,
|
|
|
|
|
});
|
|
|
|
|
// 30 sold of 100 total = 30%
|
|
|
|
|
expect(result.occupancyRate).toBe(30);
|
|
|
|
|
expect(result.totalBerths).toBe(100);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rounds to 1 decimal place', () => {
|
|
|
|
|
const result = computeOccupancyRate({
|
|
|
|
|
sold: 1,
|
|
|
|
|
available: 2,
|
|
|
|
|
});
|
|
|
|
|
// 1/3 = 33.333... → 33.3
|
|
|
|
|
expect(result.occupancyRate).toBe(33.3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 0/0 not NaN when there are no berths', () => {
|
|
|
|
|
const result = computeOccupancyRate({});
|
|
|
|
|
expect(result.occupancyRate).toBe(0);
|
|
|
|
|
expect(result.totalBerths).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 100 when every berth is sold', () => {
|
|
|
|
|
const result = computeOccupancyRate({ sold: 50 });
|
|
|
|
|
expect(result.occupancyRate).toBe(100);
|
|
|
|
|
expect(result.totalBerths).toBe(50);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('rollupBerthStatusCounts', () => {
|
|
|
|
|
it('maps per-status counts + computes total', () => {
|
|
|
|
|
const out = rollupBerthStatusCounts([
|
|
|
|
|
{ status: 'sold', count: 30 },
|
|
|
|
|
{ status: 'available', count: 60 },
|
|
|
|
|
{ status: 'under_offer', count: 10 },
|
|
|
|
|
]);
|
|
|
|
|
expect(out.statusCounts).toEqual({ sold: 30, available: 60, under_offer: 10 });
|
|
|
|
|
expect(out.totalBerths).toBe(100);
|
|
|
|
|
});
|
|
|
|
|
});
|