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>
58 KiB
Audit Cleanup Completion — Master Plan (2026-05-18, Part 2)
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Close out everything outstanding in docs/BACKLOG.md sections A, C, D, E, G, H over a sequenced ~4-week sweep, ending with the backlog drained down to "no concrete trigger" parked items only.
Architecture: Six sequenced phases. Phase A is stop-ship priority (security + data integrity). Phase B layers in HIGH security + observability. Phase C re-opens the back-burnered Documenso build (phases 2-7 + Section C deferred items bundled). Phase D is mechanical React Compiler cleanup. Phase E does Wave 6 + 7 validation work on top of the Documenso changes. Phase F mops up Section G dependency migrations + Section D placeholders + Section E hidden tabs. Each phase ships a working, testable cut; commits are atomic per item with a phase-tagged prefix.
Tech Stack: Same as the parent CRM — Next.js 16 App Router, React 19, TypeScript strict, Drizzle ORM on PostgreSQL, BullMQ + Redis, better-auth, React-Email + nodemailer + imapflow, Documenso v1/v2 client, pdf-lib for AcroForm fill.
Estimate: ~4 weeks focused work end-to-end. Phase A is the only phase where "ship before deploy" is hard-blocking — everything after is post-launch hygiene.
Pre-flight (do once)
- P.1 — Verify session-end state
git log --oneline -5
pnpm exec vitest run
pnpm exec tsc --noEmit
pnpm lint
Expected: clean tsc, 0 lint errors, 1374/1374 vitest pass (or current baseline). Capture baseline number for "no regressions" gate at end of each phase.
- P.2 — Verify Wave 2 #12 (admin subject editor) actually outstanding
grep -n "overrides?.subject" src/lib/email/templates/*.tsx | wc -l
Expected: count ≥ 8 (all templates honour overrides.subject). If yes, Wave 2 #12 is closed in code by this session's Phase 5 — only the admin UI needs verification. Confirm admin page /admin/email-templates exposes a subject input bound to that field; if so, mark #12 ✅ before Phase B starts.
- P.3 — Refresh backlog tracker
Open docs/BACKLOG.md and append a note: "2026-05-18 Part 2 plan live at docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md."
Phase A — Wave 1 stop-ship CRITICALs (~3-5 days)
Nine items, half-day each, ship in priority order. Each item is one commit with prefix fix(audit-wave-1): <item>.
Task A.1 — Real db:migrate runner
Why critical: 0052_audit_critical_fixes.sql uses CREATE INDEX CONCURRENTLY which silently never runs under db:push. Six composite indexes missing in prod. Without a real runner this only ever gets worse.
Files:
-
Create:
scripts/db-migrate.ts -
Modify:
package.json(replacedb:migratescript) -
Test:
tests/integration/scripts/db-migrate.spec.ts -
Step 1: Write failing test
// tests/integration/scripts/db-migrate.spec.ts
import { applyPendingMigrations } from '@/scripts/db-migrate-lib';
import { db } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { describe, expect, it, beforeEach } from 'vitest';
describe('db-migrate runner', () => {
beforeEach(async () => {
await db.execute(sql`DROP TABLE IF EXISTS __drizzle_migrations`);
});
it('applies pending migrations idempotently', async () => {
const result1 = await applyPendingMigrations({ dir: 'src/lib/db/migrations' });
expect(result1.applied.length).toBeGreaterThan(0);
const result2 = await applyPendingMigrations({ dir: 'src/lib/db/migrations' });
expect(result2.applied.length).toBe(0);
});
it('honours --> statement-breakpoint split and runs CREATE INDEX CONCURRENTLY outside tx', async () => {
// Verify the runner does NOT wrap CONCURRENTLY statements in a transaction
// (Postgres rejects CONCURRENTLY inside BEGIN/COMMIT).
const result = await applyPendingMigrations({ dir: 'src/lib/db/migrations' });
expect(result.errors).toEqual([]);
});
});
- Step 2: Build the runner
Extract logic into scripts/db-migrate-lib.ts (testable) and a thin CLI scripts/db-migrate.ts that calls it. Runner contract:
- Read
src/lib/db/migrations/*.sqlin lexical order. - Track applied state in
__drizzle_migrationstable (id text primary key, applied_at timestamptz). - Split each file on
--> statement-breakpoint. - For each statement: if it contains
CONCURRENTLY→ execute outside any tx; otherwise wrap the file's statements in a single tx. - On failure: log statement + abort; do not mark migration applied.
- Step 3: Rerun vitest, then wire script
pnpm exec vitest run tests/integration/scripts/db-migrate.spec.ts
Then in package.json: "db:migrate": "tsx scripts/db-migrate.ts".
- Step 4: Backfill missing indexes in dev DB
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm -c "DROP TABLE IF EXISTS __drizzle_migrations;"
pnpm db:migrate
Then verify the 6 indexes from 0052_audit_critical_fixes.sql exist:
SELECT indexname FROM pg_indexes WHERE indexname LIKE '%audit_critical%';
- Step 5: Commit
git add scripts/db-migrate.ts scripts/db-migrate-lib.ts tests/integration/scripts/db-migrate.spec.ts package.json
git commit -m "fix(audit-wave-1): real db:migrate runner with CONCURRENTLY support"
Task A.2 — EMAIL_REDIRECT_TO production guard
Why critical: Currently a dev safety net; if accidentally left set in prod, every customer email goes to one dev mailbox.
Files:
-
Modify:
src/lib/env.ts -
Modify:
src/lib/email/index.ts -
Test:
tests/unit/lib/env.spec.ts -
Step 1: Failing test
// tests/unit/lib/env.spec.ts (add to existing)
it('rejects EMAIL_REDIRECT_TO when NODE_ENV=production', () => {
expect(() =>
serverEnvSchema.parse({ ...validBase, NODE_ENV: 'production', EMAIL_REDIRECT_TO: 'x@y.com' }),
).toThrow(/EMAIL_REDIRECT_TO must not be set in production/);
});
it('accepts EMAIL_REDIRECT_TO when NODE_ENV=development', () => {
expect(() =>
serverEnvSchema.parse({ ...validBase, NODE_ENV: 'development', EMAIL_REDIRECT_TO: 'x@y.com' }),
).not.toThrow();
});
- Step 2: Update env schema
In src/lib/env.ts, add a superRefine after the base parse:
.superRefine((env, ctx) => {
if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO) {
ctx.addIssue({
code: 'custom',
path: ['EMAIL_REDIRECT_TO'],
message: 'EMAIL_REDIRECT_TO must not be set in production',
});
}
});
- Step 3: Boot warning in mailer
In src/lib/email/index.ts at module top:
if (env.EMAIL_REDIRECT_TO) {
logger.warn(
{ emailRedirectTo: env.EMAIL_REDIRECT_TO },
'EMAIL_REDIRECT_TO is set — every outbound email will be rerouted',
);
}
- Step 4: Tests + commit
pnpm exec vitest run tests/unit/lib/env.spec.ts
git add src/lib/env.ts src/lib/email/index.ts tests/unit/lib/env.spec.ts
git commit -m "fix(audit-wave-1): reject EMAIL_REDIRECT_TO in production"
Task A.3 — Orphan-blob fix in handleDocumentCompleted
Why critical: Current catch-block leaves blob in storage AND marks status='completed' with no signedFileId. Storage cost leak + document marked done with no signed PDF retrievable.
Files:
-
Modify:
src/lib/services/documents.service.ts:1100-1253 -
Test:
tests/integration/services/documents-completion.spec.ts -
Step 1: Failing test
// tests/integration/services/documents-completion.spec.ts
it('rolls back storage + DB when files.insert fails mid-completion', async () => {
// Arrange: stub storage.put to succeed, files.insert to throw
const putSpy = vi.spyOn(storage, 'put').mockResolvedValueOnce({ key: 'k', sha256: 'x', size: 1 });
vi.spyOn(db.insert(filesTable), 'values').mockRejectedValueOnce(new Error('boom'));
await expect(handleDocumentCompleted({ documentId: doc.id, pdfBytes })).rejects.toThrow();
// Assert: blob deleted from storage
expect(await storage.head('k').catch(() => null)).toBeNull();
// Assert: documents.status NOT updated to completed
const after = await db.query.documents.findFirst({ where: eq(documents.id, doc.id) });
expect(after?.status).not.toBe('completed');
expect(after?.signedFileId).toBeNull();
});
- Step 2: Rewrite as saga
Inside handleDocumentCompleted:
storage.put(...)→ capture key.withTransaction(async (tx) => { insert files, update documents }).- On tx failure:
await storage.delete(key)infinally/catch, then rethrow.
Idempotency early-return (status='completed' && signedFileId) stays at the top of the function — already handles 5xx retries.
- Step 3: Verify + commit
pnpm exec vitest run tests/integration/services/documents-completion.spec.ts
git add src/lib/services/documents.service.ts tests/integration/services/documents-completion.spec.ts
git commit -m "fix(audit-wave-1): handleDocumentCompleted saga compensating delete"
Task A.4 — Escape URLs in email templates
Why critical: Every template inlines ${data.link} into href="…" without escaping. URL injection risk (template merge tokens could carry a crafted URL).
Note: safeUrl() already exists in src/lib/email/shell.ts and is used by most templates. This task sweeps the few that don't use it.
Files (audit each):
-
src/lib/email/templates/portal-auth.tsx -
src/lib/email/templates/inquiry-client-confirmation.tsx -
src/lib/email/templates/inquiry-sales-notification.tsx -
src/lib/email/templates/notification-digest.tsx -
src/lib/email/templates/document-signing.tsx -
src/lib/email/templates/admin-email-change.tsx -
src/lib/email/templates/crm-invite.tsx -
src/lib/email/templates/residential-inquiry.tsx -
Step 1: Grep for unsafe usages
grep -rn 'href={data\.\|href={`' src/lib/email/templates/
For each match: wrap with safeUrl(...). Schema: safeUrl already allows only http/https/mailto.
- Step 2: Add safeUrl coverage test
// tests/unit/lib/email/shell.spec.ts (add)
it.each([
['javascript:alert(1)', '#'],
['data:text/html,xss', '#'],
['ftp://x', '#'],
['https://ok.com/path', 'https://ok.com/path'],
['mailto:a@b.c', 'mailto:a@b.c'],
])('safeUrl(%s) → %s', (input, expected) => {
expect(safeUrl(input)).toBe(expected);
});
- Step 3: Commit
git add src/lib/email/templates/ tests/unit/lib/email/shell.spec.ts
git commit -m "fix(audit-wave-1): route all email template URLs through safeUrl"
Task A.5 — Replace 16 native window.confirm() calls
Why critical: Destructive flows bypass ConfirmationDialog/AlertDialog. ui-ux-auditor's C1 itemized them. Inconsistent UX + native confirm can't be styled or scripted.
Files (16 sites — grep to enumerate):
grep -rn 'window.confirm\|confirm(' src/components/ src/app/ --include='*.tsx' | grep -v node_modules | grep -v 'window\.confirmDocumentMooring'
Expected sites include: cancel signing, delete file, archive interest/company/yacht, delete folder, etc.
- Step 1: Identify shared dialog primitive
ConfirmationDialog exists at src/components/shared/confirmation-dialog.tsx. Pattern: useState<boolean> for open, <ConfirmationDialog title="…" description="…" confirmLabel="Archive" variant="destructive" onConfirm={…} />.
- Step 2: Sweep — 30 min per site × 16
For each window.confirm(...) call:
- Add
useStatefor dialog open. - Replace the
if (window.confirm(...))guard withsetConfirmOpen(true). - Move action body into
onConfirmhandler. - Render
<ConfirmationDialog open={confirmOpen} onOpenChange={setConfirmOpen} ... />.
- Step 3: Add ESLint rule against
window.confirm
In eslint.config.mjs:
{
rules: {
'no-restricted-globals': ['error', { name: 'confirm', message: 'Use <ConfirmationDialog> instead' }],
},
}
- Step 4: Commit
git add src/components/ src/app/ eslint.config.mjs
git commit -m "fix(audit-wave-1): replace 16 window.confirm() with ConfirmationDialog"
Task A.6 — GDPR Article-15 export completeness
Why critical: Regulator-finding-level gap. Current bundle missing: portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions.
Files:
-
Modify:
src/lib/services/gdpr-bundle-builder.ts -
Test:
tests/integration/services/gdpr-bundle.spec.ts -
Step 1: Enumerate missing relations
For a single test client, seed at least one row in each missing table linking back via clientId (or polymorphic FK where applicable).
- Step 2: Failing test
it('includes every PII-bearing relation in the GDPR bundle', async () => {
const bundle = await buildGdprBundle(testClient.id);
expect(Object.keys(bundle)).toEqual(
expect.arrayContaining([
'client',
'addresses',
'contacts',
'interests',
'notes',
'documents',
'tags',
// newly required:
'portalUsers',
'emailThreads',
'emailMessages',
'documentSends',
'reminders',
'files',
'scratchpadNotes',
'clientMergeLog',
'contactLog',
'websiteSubmissions',
'formSubmissions',
]),
);
});
- Step 3: Add the 10 missing relation loaders
Each loader is a Drizzle query bounded by port_id + client_id (or polymorphic resolver where the relation doesn't carry client_id directly — use the symmetric-reach pattern from listFilesAggregatedByEntity).
- Step 4: Commit
git add src/lib/services/gdpr-bundle-builder.ts tests/integration/services/gdpr-bundle.spec.ts
git commit -m "fix(audit-wave-1): GDPR bundle includes 10 missing relations"
Task A.7 — Right-to-be-forgotten true wipe
Why critical: client-hard-delete.service.ts nullifies FKs but leaves verbatim PII in email_messages.body_html, files, document_sends.recipient_email. Doesn't satisfy Article-17.
Files:
-
Modify:
src/lib/services/client-hard-delete.service.ts -
Test:
tests/integration/services/client-hard-delete.spec.ts -
Step 1: Failing test
it('overwrites PII in email_messages.body_html and document_sends', async () => {
await hardDeleteClient({ clientId, requestedBy: actor });
const msgs = await db.query.emailMessages.findMany({
where: eq(emailMessages.clientId, clientId),
});
msgs.forEach((m) => {
expect(m.bodyHtml).toBe('');
expect(m.bodyText).toBe('');
expect(m.subject).toBe('[erased]');
});
const sends = await db.query.documentSends.findMany({
where: eq(documentSends.clientId, clientId),
});
sends.forEach((s) => expect(s.recipientEmail).toBe('[erased]'));
});
it('deletes file blobs from storage', async () => {
const file = await db.query.files.findFirst({ where: eq(files.clientId, clientId) });
await hardDeleteClient({ clientId, requestedBy: actor });
expect(await storage.head(file!.storageKey).catch(() => null)).toBeNull();
});
- Step 2: Add wipe pass
Inside the existing tx:
UPDATE email_messages SET body_html = '', body_text = '', subject = '[erased]' WHERE client_id = $1.UPDATE document_sends SET recipient_email = '[erased]' WHERE client_id = $1.- For each
filesrow: delete blob viastorage.delete(key), then delete the row. Errors on storage.delete should not block tx — log and continue (blob orphan is acceptable; row deletion is the legally required action).
- Step 3: Commit
git add src/lib/services/client-hard-delete.service.ts tests/integration/services/client-hard-delete.spec.ts
git commit -m "fix(audit-wave-1): client hard-delete true-wipe PII text + blobs"
Task A.8 — user_permission_overrides.user_id FK + onDelete='set null'
Why critical: Orphan rows accumulate when users are deleted.
Files:
-
Create:
src/lib/db/migrations/0076_user_permission_overrides_fk.sql -
Step 1: Write migration
ALTER TABLE user_permission_overrides
ADD CONSTRAINT user_permission_overrides_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
- Step 2: Apply + verify
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm -f src/lib/db/migrations/0076_user_permission_overrides_fk.sql
Then in the Drizzle schema src/lib/db/schema/permissions.ts (or wherever userPermissionOverrides lives): add .references(() => users.id, { onDelete: 'set null' }) to the column.
- Step 3: Commit
git add src/lib/db/migrations/0076_user_permission_overrides_fk.sql src/lib/db/schema/
git commit -m "fix(audit-wave-1): user_permission_overrides.user_id FK + set null"
Task A.9 — Resolve-identifier endpoint replacement
Why critical: Currently echoes the real canonical email on a successful username hit. Even rate-limited, this is a username-to-email mapping oracle.
Files:
-
Delete (or refactor):
src/app/api/auth/resolve-identifier/route.ts -
Modify:
src/app/(auth)/login/page.tsx(form action) -
Create:
src/app/api/auth/sign-in-proxy/route.ts -
Test:
tests/integration/api/auth-sign-in-proxy.spec.ts -
Step 1: Failing test
it('never returns canonical email on identifier+password attempt', async () => {
// Success path
const ok = await POST(req({ identifier: 'username1', password: 'correct-pass' }));
const okBody = await ok.json();
expect(okBody).not.toHaveProperty('email');
expect(okBody.data).toMatchObject({ ok: true });
// Failure path
const bad = await POST(req({ identifier: 'username1', password: 'wrong' }));
const badBody = await bad.json();
expect(badBody).not.toHaveProperty('email');
});
- Step 2: Build proxy
New endpoint accepts { identifier, password }, resolves canonical email server-side, calls auth.api.signInEmail({ body: { email, password } }), returns { data: { ok: true } } with session cookie set on success, { error: { code: 'INVALID_CREDENTIALS' } } on failure. Same response for "no such identifier" and "wrong password" (timing-equalize with a dummy bcrypt compare on miss).
- Step 3: Update login form
src/app/(auth)/login/page.tsx posts to the proxy directly with the user-entered identifier; no two-step flow.
- Step 4: Delete old endpoint
git rm src/app/api/auth/resolve-identifier/route.ts
- Step 5: Commit
git add src/app/api/auth/sign-in-proxy/ src/app/\(auth\)/login/page.tsx tests/integration/api/auth-sign-in-proxy.spec.ts
git commit -m "fix(audit-wave-1): sign-in proxy replaces resolve-identifier email leak"
Phase A acceptance gate
pnpm exec vitest run— all green, ≥ baseline countpnpm exec tsc --noEmit— cleanpnpm lint— 0 errorspnpm exec playwright test --project=smoke— green- All 9 commits land
- Update
docs/BACKLOG.md§H Wave 1 — mark all 9 items ✅ - Hand off Phase B
Phase B — Wave 2 HIGH security + observability (~5-7 days)
Six items, ~half-day each. Prefix: fix(audit-wave-2): <item>.
Task B.1 — audit_logs.metadata PII masking + 90-day retention
Files:
-
Modify:
src/lib/audit.ts(extendmaskSensitiveFieldsto covermetadataJSONB) -
Create:
src/jobs/processors/audit-retention.ts(90-day cron) -
Modify:
src/lib/queue/scheduler.ts(register cron) -
Test:
tests/unit/lib/audit-mask.spec.ts -
Step 1: Failing test for masking
it('masks email/phone/password/token in metadata JSONB', () => {
const masked = maskSensitiveFields({
metadata: { email: 'a@b.c', phone: '+44...', password: 'x', other: 'keep' },
});
expect(masked.metadata.email).toBe('[redacted]');
expect(masked.metadata.phone).toBe('[redacted]');
expect(masked.metadata.password).toBe('[redacted]');
expect(masked.metadata.other).toBe('keep');
});
- Step 2: Add retention worker
Mirror the error_events retention worker. Daily cron deleting rows where created_at < now() - interval '90 days'. Register in scheduler.
- Step 3: Commit
git commit -m "fix(audit-wave-2): audit_logs metadata PII mask + 90d retention"
Task B.2 — Webhook → error pipeline
Files (sweep):
-
src/app/api/webhooks/documenso/route.ts -
src/app/api/webhooks/imap-bounce/route.ts(if any) -
Any other
/api/webhooks/*routes -
Step 1: Locate webhook routes
ls src/app/api/webhooks/
- Step 2: Wrap each handler
try {
await handle(req);
} catch (err) {
await captureErrorEvent(err, { source: 'webhook:documenso', requestId });
throw err;
}
- Step 3: Test that thrown errors are captured
For each webhook, integration-test that a forced throw lands in error_events.
- Step 4: Commit
git commit -m "fix(audit-wave-2): webhooks emit errors to captureErrorEvent"
Task B.3 — Admin email-template subject editor wiring verification
Status: Phase 5 of the post-audit master plan rewrote all 8 templates to honour overrides.subject. This task confirms the admin UI surfaces it and adds a test.
Files:
-
Verify:
src/app/(dashboard)/[portSlug]/admin/email-templates/page.tsx -
Verify:
src/lib/services/mailer-config.tsgetTemplateOverridesForKey() -
Test:
tests/integration/services/mailer-config.spec.ts -
Step 1: Manually walk admin page — confirm subject input present + saves.
-
Step 2: Integration test that override is applied
it('honours subject override from email_templates table', async () => {
await db
.update(emailTemplates)
.set({ subject: 'Custom override' })
.where(eq(emailTemplates.key, 'portal_activation'));
const result = await portalAuthEmail(testData, {
subject: await getTemplateOverridesForKey('portal_activation').subject,
});
expect(result.subject).toBe('Custom override');
});
- Step 3: Commit (or close as already-done if test passes against current code)
git commit -m "test(audit-wave-2): verify admin subject override flows to render"
Task B.4 — Admin signature/footer fields wire-up OR delete UI
Files:
-
Modify:
src/lib/email/shell.ts(reademail_signature_html+email_footer_html) -
Modify:
src/app/(dashboard)/[portSlug]/admin/email/page.tsx(if deleting) -
Step 1: Decide — wire or delete
If admin already uses signature/footer in practice (check system_settings for non-null rows): wire. If unused: delete the UI and the columns.
- Step 2 (wire path): Extend shell
renderShell({...}) accepts optional signatureHtml + footerHtml from per-port system_settings. Both run through allowlist sanitizer (isomorphic-dompurify already in deps).
- Step 3 (wire path): Plumb in
mailer-config.ts
Load signature/footer alongside branding in every send* site.
- Step 4: Commit
git commit -m "fix(audit-wave-2): wire admin signature/footer through email shell"
Task B.5 — PII redaction in error pipeline
Files:
-
Modify:
src/lib/observability/error-events.ts(or whereverrequest_body_excerptsanitizer lives) -
Test:
tests/unit/lib/observability/error-sanitizer.spec.ts -
Step 1: Failing test
it.each([
['email', { email: 'a@b.c' }, /\[redacted\]/],
['phone', { phone: '+44 1234 567890' }, /\[redacted\]/],
['firstName', { firstName: 'Alice' }, /\[redacted\]/],
['dateOfBirth', { dob: '1990-01-01' }, /\[redacted\]/],
['address', { addressLine1: '10 Downing St' }, /\[redacted\]/],
])('redacts %s', (_, input, expected) => {
expect(JSON.stringify(sanitize(input))).toMatch(expected);
});
- Step 2: Extend sanitizer key list
Add: email, phone, firstName, lastName, fullName, dateOfBirth, dob, addressLine1, addressLine2, city, postalCode, country.
- Step 3: Commit
git commit -m "fix(audit-wave-2): error sanitizer redacts PII keys"
Task B.6 — Notification email worker XSS
Files:
-
Modify:
src/lib/queue/workers/notifications.ts:65-71 -
Test:
tests/unit/queue/notifications-xss.spec.ts -
Step 1: Failing test
it('escapes notification description and link in HTML', async () => {
const html = await buildNotificationHtml({
description: '<script>alert(1)</script>',
link: 'javascript:alert(1)',
});
expect(html).not.toContain('<script>');
expect(html).toContain('<script>');
expect(html).not.toMatch(/href="javascript:/);
});
- Step 2: Apply
escapeHtml+safeUrl
Wrap both interpolation sites. escapeHtml already exists in src/lib/email/shell.ts.
- Step 3: Commit
git commit -m "fix(audit-wave-2): notification email worker escapes user content"
Phase B acceptance gate
- All 6 commits land
- No regressions in vitest / tsc / lint / smoke
docs/BACKLOG.md§H Wave 2 marked ✅
Phase C — Documenso phases 2-7 + Section C bundled deferred items (~30-40h)
Sequencing: Phase 7 first (1h independent pickoff), then Phase 2 (3-4h, foundation for everything else), then Section C Documenso-related items (bundled with Phase 2), then Phase 3 (6-8h custom upload), then Phase 4 (10-14h field placement UI — partially built by Phase 7 template editor this session), then Phase 5 (1-2h verification needing live v2), then Phase 6 polish (each 2-3h, optional).
Prefix: feat(documenso-p<N>): <scope>.
Task C.1 — Documenso Phase 7 (Project Director RBAC)
Files:
-
Modify:
src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx -
Modify:
src/lib/services/documenso-client.ts(developer/approver user mapping) -
Schema fields
documenso_developer_user_id,documenso_approver_user_id,_labelalready exist. -
Step 1: Add user-picker dropdown to admin page
<Select> populated from /api/v1/users (current port). On select: store user id + label in system_settings. Auto-fill developer_email + developer_name from picked user.
- Step 2: Webhook handler matches against linked user's email
In src/app/api/webhooks/documenso/route.ts: when handling signing events, if recipient.email matches linked developer/approver user email, also push status update into that user's in-CRM notification.
- Step 3: Commit
git commit -m "feat(documenso-p7): Project Director RBAC UI binding"
Task C.2 — Documenso Phase 2 + Section C deferred items (bundled)
Why bundled: Section C items 1-2 (webhook port_id enforcement, recipient_email dedup) touch the same handler code as Phase 2.
Files:
-
Modify:
src/app/api/webhooks/documenso/route.ts(cascading "your turn" emails, token-based recipient matching, idempotency lock, port_id enforcement) -
Create:
src/lib/db/migrations/0077_document_events_recipient_email.sql(addrecipient_emailcolumn + composite unique) -
Schema: add
recipient_emailtodocumentEvents -
Step 1: Migration
ALTER TABLE document_events ADD COLUMN recipient_email text;
DROP INDEX IF EXISTS uniq_document_events_body_hash;
CREATE UNIQUE INDEX uniq_document_events_dedup
ON document_events (documenso_document_id, recipient_email, event_type)
WHERE recipient_email IS NOT NULL;
-- Fallback unique on body_hash for events without recipient context
CREATE UNIQUE INDEX uniq_document_events_body_hash_fallback
ON document_events (body_hash)
WHERE recipient_email IS NULL;
- Step 2: Enforce port_id
Every db.query.documents.findFirst({ where: ... }) in the webhook handler gains a port_id derivation from the document's signer-set + recipient_email match, asserts the match, refuses to act if mismatched.
- Step 3: Cascading "your turn" emails
When RECIPIENT_SIGNED lands and there's a next signer in sequence (document_signers.order ascending), send a "your turn" notification to that signer. Use document-signing.tsx template with nextSignerToken deep link.
- Step 4: On-completion PDF distribution
When DOCUMENT_COMPLETED lands: pull documents.completion_cc_emails (already present from Phase 1) and attach signed PDF to each as a separate send via sendMail({ to, attachments: [{ filename, content }] }).
- Step 5: Token-based recipient matching
Use document_signers.signing_token as the lookup key when webhook payload includes a token (v2 recipient.token or v1 signer.signingToken).
- Step 6: Idempotency lock
pg_advisory_xact_lock(hashtext($1)) keyed on documenso_document_id for the duration of the handler.
- Step 7: Tests
// tests/integration/webhooks/documenso-cascading.spec.ts
it('sends your-turn email to next signer in sequence');
it('attaches signed PDF to completion_cc_emails on COMPLETED');
it('refuses to act on document with mismatched port_id');
it('deduplicates per (documensoDocumentId, recipientEmail, eventType)');
it('serializes concurrent webhook deliveries via advisory lock');
- Step 8: Commit
git commit -m "feat(documenso-p2): webhook cascading + port_id + per-recipient dedup + idempotency"
Task C.3 — Documenso Phase 3 (Custom doc upload-to-Documenso)
Files:
-
Create:
src/lib/services/custom-document-upload.service.ts -
Create:
src/app/api/v1/interests/[id]/upload-for-signing/route.ts+ siblinghandlers.ts -
Create: UI surface — likely a button in
<InterestDocumentsTab>opening the field-placement UI from Phase 4 -
Test:
tests/integration/services/custom-document-upload.spec.ts -
Step 1: Service contract
export async function uploadCustomDocumentForSigning(opts: {
interestId: string;
uploadedFile: { name: string; bytes: Buffer; mime: string };
signers: Array<{
role: 'signer' | 'cc' | 'approver';
email: string;
name: string;
order?: number;
}>;
fieldPlacements: Array<{
recipientEmail: string;
type: string;
page: number;
x: number;
y: number;
w: number;
h: number;
}>;
sendImmediately?: boolean;
}): Promise<{ documentId: string; documentRecordId: string }>;
-
Step 2: Implementation — v1 path uses
/api/v1/documents+/api/v1/documents/{id}/fields+/distribute. v2 path uses/api/v2/envelopemultipart create +field/create-many+distribute. Routes through existingdocumenso-client.tswrappers. -
Step 3: API route
withAuth(withPermission('documents.create_for_signing')), parseBody schema validates multipart, returns { data: { documentRecordId, documensoDocumentId } }.
- Step 4: Commit
git commit -m "feat(documenso-p3): custom doc upload-for-signing endpoint + service"
Task C.4 — Documenso Phase 4 (Field placement UI)
Note: Phase 7 template editor (this session) covers click-place / drag / 4-corner resize / multi-page / right-click delete / responsive PDF width / unsaved guard. Re-use that component. New piece is the auto-detect anchor scanner via pdfjs-dist getTextContent.
Files:
-
Modify: existing template editor
src/components/admin/templates/template-editor.tsx→ extract a reusable<FieldPlacementCanvas>component -
Create:
src/lib/pdf/anchor-scanner.ts(text-position extractor) -
Create: UI surface where user uploads PDF + previews scanner results
-
Step 1: Extract reusable canvas component
The template-editor.tsx state machine for markers + drag + resize lifts into <FieldPlacementCanvas pdfUrl onMarkersChange initialMarkers signers />. Template editor becomes a thin wrapper around it; custom-upload UI uses the same component.
- Step 2: Anchor scanner
export async function scanAnchors(
pdfBytes: Uint8Array,
patterns: Array<{ regex: RegExp; fieldHint: string }>,
): Promise<Array<{ page: number; x: number; y: number; matched: string; fieldHint: string }>>;
Uses pdfjs-dist getTextContent per page. Default patterns from build-plan Phase 4 (signature, date, full name, initials).
- Step 3: UI
"Scan for anchors" button on the field-placement screen → shows ghost markers at detected positions → user accepts/rejects → markers go into placement set.
- Step 4: Commit
git commit -m "feat(documenso-p4): field placement canvas + anchor scanner"
Task C.5 — Documenso Phase 5 + Section C v2 voidDocument verification (bundled)
Note: Needs a live v2 instance. Defer if unavailable.
Files:
-
Verify:
signerMessagesmap in website project -
Verify:
voidDocumentv2 endpoint shape indocumenso-client.ts -
Modify: nginx CORS block per integration audit
-
Step 1: With live v2 instance — POST a doc + cancel it
-
Step 2: Confirm
documenso-client.ts:voidDocumentpayload shape matches v2 -
Step 3: Update
signerMessagesmap across signer-role × documentType combinations -
Step 4: Apply nginx CORS block
git commit -m "feat(documenso-p5): embedded signing URL + v2 void verification"
Task C.6 — Documenso Phase 6 polish (optional, each ~2-3h)
Itemize → pick subset based on appetite. Items: auto-send delay, audit-log additions, per-document customisation, document expiration, reminder rate-limit display, failed-webhook recovery UI. Defer all six if appetite is exhausted.
Task C.7 — Section C: public POST routes refactor
Files:
-
Create:
src/lib/services/public-interest.service.ts -
Modify:
src/app/api/public/{interests,website-inquiries,residential-inquiries}/route.ts -
Step 1: Extract
publicInterestService.create(...)from each public route -
Step 2: Routes become thin wrappers
-
Step 3: Service unit tests cover dedup, port resolution, branding fan-out
-
Step 4: Commit
git commit -m "refactor(public-routes): extract publicInterestService"
Phase C acceptance gate
- Phase 7, 2, 3, 4 ship at minimum (Phase 5 + 6 + 7-refactor optional based on live-v2 availability + appetite)
- Section C Documenso-related items closed via Phase 2
- Section C public-POST refactor closed via C.7
Phase D — Wave 3 React Compiler set-state-in-effect cleanup (~1 day)
40 remaining warnings. Two templates established:
- List/load pattern → TanStack Query. Template:
src/components/admin/tags/tag-list.tsx. - Dialog open→reset pattern → keyed inner DialogBody. Template:
src/components/clients/hard-delete-dialog.tsx.
Prefix: fix(audit-wave-3): <component>.
- Step 1: Enumerate
pnpm lint 2>&1 | grep -E 'react-hooks/set-state-in-effect' | sed 's/^.*: //' | sort -u
Should yield ~40 file:line tuples.
- Step 2: Migrate in batches of ~10
For each file:
- If
useEffect → fetch → setState→ switch touseQuery({ queryKey, queryFn }). - If
useEffectresetting form on prop change → keyed inner component.
Run lint after each batch; verify count drops.
- Step 3: Promote rule to error
In eslint.config.mjs:
'react-hooks/set-state-in-effect': 'error',
- Step 4: Commit (one per batch)
git commit -m "fix(audit-wave-3): migrate batch <N> to TanStack Query"
Final commit:
git commit -m "fix(audit-wave-3): promote set-state-in-effect to error"
Phase D acceptance gate
pnpm lint— 0set-state-in-effectwarnings- Rule is
errorin eslint.config.mjs - No vitest / smoke regressions
Phase E — Wave 6 Documenso depth + Wave 7 reporting/recommender quality (~1 week)
Validation work on top of Phase C. Two sub-phases.
Task E.1 — Wave 6 Documenso integration depth audit
Files: all of src/lib/services/documenso-client.ts, src/app/api/webhooks/documenso/
- Step 1: Read
docs/documenso-integration-audit.mdend-to-end - Step 2: Live-v2 instance walkthrough — every webhook event type, every API call wrapped by documenso-client
- Step 3: Verify per-port redirect URL, sequential signing flag, signing-URL emission
- Step 4: File audit results in
docs/documenso-depth-audit-2026-05-19.mdwith findings - Step 5: Address any P0/P1 findings — open as commits
Task E.2 — Wave 6 Email deliverability sanity check
- Step 1: Verify bounce monitoring against real bounced delivery (Phase 6 IMAP poller from prior session — needs ops to seed
IMAP_*env + send a bounce-bait email) - Step 2: Attachment threshold UX — confirm 24h signed-URL link path renders sensibly when files exceed
email_attach_threshold_mb
Task E.3 — Wave 7 reporting math correctness
Files:
-
src/lib/services/reports/revenue.ts -
src/lib/services/reports/pipeline-funnel.ts -
src/lib/services/reports/occupancy.ts -
Step 1: Build a deterministic test fixture — synthetic port with 50 interests across 7 stages over 12 months
-
Step 2: Hand-compute expected revenue, funnel rates, occupancy for the fixture
-
Step 3: Snapshot test against current code output; reconcile any divergence
Task E.4 — Wave 7 berth recommender quality
- Step 1: Build tier-ladder edge-case fixture — interests at every boundary (just under/over each tier threshold)
- Step 2: Snapshot expected tier assignments + heat-score order
- Step 3: Verify against current
berth-recommender.service.tsoutput
Phase E acceptance gate
- Documenso depth audit doc published
- Bounce monitoring round-trip green
- Reporting + recommender snapshot tests in place + green
- Mark Waves 6 + 7 ✅ in BACKLOG
Phase F — Section G opportunistic + Section D placeholders + Section E hidden tabs (rolling)
Lowest priority. Ship as touched.
Task F.1 — Section G .toLocale* → formatDate(...) rolling sweep
93 sites. Migrate when a file is touched for another reason. No standalone PR — flag it in the commit message.
Task F.2 — Section G drizzle-zod for simple validators
~28 candidates. Migrate when validator file is touched. Same as F.1.
Task F.3 — Section G <DataTable virtual /> wiring
Three concrete sites: admin/audit-log-list, super-admin port switcher, client export modal. ~15min each.
git commit -m "perf(audit-section-g): virtualize admin/audit-log-list"
git commit -m "perf(audit-section-g): virtualize super-admin port switcher"
git commit -m "perf(audit-section-g): virtualize client export modal"
Task F.4 — Section D scheduler.ts:44 per-user reminder schedule
Placeholder — no concrete trigger. Decision: leave as-is, document explicitly in docs/BACKLOG.md §D.
Task F.5 — Section D workers/import.ts:13 CSV/Excel import
Placeholder — nothing enqueues import jobs. Decision: leave as-is, same.
Task F.6 — Section E Interest Contract/Reservation tabs
Gated on: Phase C (Documenso 2-6) shipping.
After Phase C lands, replace the "coming soon" cards with real flow:
- Contract tab → custom-doc upload + signing status timeline
- Reservation tab → berth_reservations CRUD + Documenso send-out
git commit -m "feat(audit-section-e): interest contract + reservation tabs"
Phase F acceptance gate
- Rolling sweeps documented as "in-progress" in BACKLOG (not "outstanding")
- Hidden tabs delivered if Phase C complete
Final action (end of plan)
- Update
docs/BACKLOG.md— all addressed sections marked ✅, parked items documented - Update
docs/MASTER-PLAN-2026-05-18.md— note completion of Part 2 - Final session commit:
docs(plan): close audit cleanup Part 2 — sections A, B, C, D, E, F, G, H closed - Restart dev server + open Playwright for manual verification
Risk + scope notes
- Phase A is the only hard-blocker. Everything else is post-launch hygiene.
- Phase C Phase 5 needs a live v2 Documenso instance. Defer if not available; doesn't block 7/2/3/4.
- Phase E reporting math may surface real bugs — budget for follow-up commits.
- Phase F.4 + F.5 are explicitly DEFERRED; included so a future engineer sees the explicit decision.
- If 4 weeks is too long: ship Phase A alone (3-5 days) for production readiness; everything else becomes future-rolling work.
VERIFICATION SUPPLEMENT (2026-05-18, post-plan)
This section supersedes the phase scoping above. A 5-agent Sonnet 1M verification sweep against current
mainfound that ~60% of items in the plan are already shipped. Real outstanding scope shrinks from ~4 weeks to ~1.5 weeks of focused work. Read this section first; treat phases A-F above as historical context.Verification methodology: parallel Explore agents with
model: "claude-sonnet-4-6[1m]"explicit override, narrow scope (4-5 items each), full file:line evidence trail. All findings cross-cite source files at specific lines.
Verified status — 38 items checked
✅ Already DONE (23 items — no work needed)
| Item | Evidence summary |
|---|---|
| A.1 — db:migrate runner | scripts/db-migrate.ts (3 modes: apply/status/baseline, CONCURRENTLY-aware, journal-ordered, hash-tracked in __drizzle_migrations) |
| A.2 — EMAIL_REDIRECT_TO prod guard | src/lib/env.ts:115-130 superRefine |
A.3 — Orphan-blob tx in handleDocumentCompleted |
documents.service.ts:1369 idempotency + :1449 db.transaction() + :1456 SELECT FOR UPDATE re-check + :1546-1558 compensating storage.delete(putStoragePath) |
| A.4 — URL escape in templates | safeUrl() consistent across all 7 templates; 0 raw hrefs |
A.5 — window.confirm() replacement |
0 call sites; use-confirmation.tsx:32 JSDoc references 17 migrated flows |
| A.6 — GDPR Article-15 completeness | gdpr-bundle-builder.ts:25-244 — all 10 required relations loaded (portalUsers, emailThreads/Messages, documentSends, reminders, files, scratchpadNotes, clientMergeLog, interestContactLog, websiteSubmissions, formSubmissions) |
| A.9 — Resolve-identifier replacement | sign-in-by-identifier/route.ts — .invalid TLD timing-safe miss response, IP rate-limit with Retry-After, server-side resolution never leaks canonical email |
W2.10 — audit_logs.metadata mask + 90d retention |
audit.ts:274 mask at insert; maintenance.ts:151-162 retention; scheduler.ts:72 '15 6 * * *' daily cron (minor camelCase fragment gap — see W2.14 below) |
| W2.11 — Webhook → error pipeline | webhooks/documenso/route.ts:313-323 captureErrorEvent wrapped in withPublicContext ALS frame |
| W2.12 — Subject editor wiring | 8/8 templates honor overrides.subject; 6 use ?.trim(), 2 use equivalent ternary. Admin UI at admin/email-templates/page.tsx |
| W2.13 — Signature/footer keys | Old email_signature_html/email_footer_html REMOVED; replaced by branding_email_header_html/branding_email_footer_html, wired through port-config.ts:100-101/:549-550 → shell.ts:31-32,44-45,69,71 |
| W2.15 — Notification email XSS | notifications.ts:8 safeUrl import + :12-19 escapeHtml + :82 description escape + :84 href safeUrl |
| A.Phase 3 — Custom-doc upload-for-signing | custom-document-upload.service.ts + api/v1/interests/[id]/upload-for-signing/route.ts both shipped |
| A.Phase 5 — Embedded signing URL + redirect | documenso_redirect_url end-to-end (registry → port-config:94/408 → documenso-client.ts:314,379 → payload:318); EmbeddedSigningCard + test endpoint shipped |
| A.Phase 7 — Project Director RBAC | Registry user-select field type at registry.ts:116-123,162-168; rendered via RegistryDrivenForm in admin/documenso/page.tsx:244-248; webhook fires document_signing_your_turn in-CRM notification |
| A.Risk #4 — v2 payload audit | payload.id: number | string; resolveRecipientToken covers r.token/r.signingToken/URL fallback (route.ts:80-85); doc lookup via or(documensoId, documensoNumericId) |
| C.1 — Webhook port_id enforcement | resolveWebhookDocument gates with eq(documents.portId, portId) (documents.service.ts:1070-1078); ambiguous cross-port match refuses mutation; portScope forwarded to every event handler |
| C.3 — v2 voidDocument shape | Cleanly branches v2 → DELETE /api/v2/envelope/{id} vs v1 → DELETE /api/v1/documents/{id} (documenso-client.ts:1286-1309); idempotent on 404 |
| E.1 — Berth Waiting List + Maintenance tabs | Removed entirely; comment at berth-tabs.tsx:449-452 documents the removal |
| E.2 — Interest Contract + Reservation tabs | NOT "coming soon" — both fully wired with active-doc hero card, signers, signing progress, cancel/remind, upload-for-signing, mark-externally-signed |
| W3 — set-state-in-effect cleanup | 0 of 40 remaining. Entire phase already done. |
| W6.bounce — IMAP bounce poller | imap-bounce-poller.ts shipped; scheduled */15 * * * * via scheduler.ts:59; no-op when IMAP env unset; state persisted in system_settings.bounce_poller_state |
| G.4 — Tier-2 deps | All 12 installed (@sentry, @use-gesture, archiver, embla-carousel, next-intl, pdfjs-dist, react-number-format, react-resizable-panels, react-virtuoso, yet-another-react-lightbox, eslint, type-fest) |
❌ OUTSTANDING (15 items — real work needed)
Ranked by impact + remaining effort:
Tier 1 — security/data-integrity (ship before next deploy)
| Item | Concrete gap | Effort |
|---|---|---|
| A.7 — RTBF true wipe | client-hard-delete.service.ts only nullifies FKs. Three gaps: (1) no UPDATE on email_messages.body_html/body_text/subject, (2) no UPDATE redacting document_sends.recipient_email, (3) files.clientId nulled but storage.delete(key) never called → orphaned blobs |
4h |
A.8 — user_permission_overrides FK |
Schema declares onDelete: 'cascade' (users.ts:319), not 'set null' as audit requires. Migration 0070 skips this table. |
30min |
| W2.14 — PII redaction in error pipeline | 6 of 13 PII keys NOT caught (firstName, lastName, dateOfBirth, city, postalCode, country) — masker fragments are snake_case but request bodies are camelCase, and the normalizer only swaps hyphens→underscores. Same gap also affects W2.10 (shared masker). A 5xx on /api/v1/clients with camelCase payload leaks these into error_events.request_body_excerpt. |
2h |
Tier 2 — Documenso completion (scope drastically reduced from original plan)
| Item | Concrete gap | Effort |
|---|---|---|
| A.Phase 2 — completion_cc_emails distribution | Column documents.completionCcEmails exists in schema (documents.ts:103). handleDocumentCompleted never reads it. Only piece of Phase 2 not done. |
2h |
| A.Phase 4 — Auto-detect button wiring | document-field-detector.ts (298 lines, full regex anchor scanner with proximity-based recipient inference) AND template-editor.tsx (680 lines, full click-place/drag/resize/multi-page) both exist. Only missing: button in editor that POSTs PDF to a server action calling detectFields(), then pre-populates markers. |
3h |
| A.Phase 6 — Polish items | All four missing: auto-send delay, expiration UI, reminder rate-limit display, failed-webhook recovery UI. Each independent. | 2-3h each, optional |
C.2 — documentEvents.recipient_email column |
Column doesn't exist; only brochure_sends.recipient_email exists. Needs migration + composite unique index for per-recipient dedup. |
1h |
C.4 — publicInterestService extraction |
Three public POST routes duplicate inline DB logic. Pure refactor, no active bug. | 2h |
Tier 3 — reporting/recommender test coverage (correctness validation)
| Item | Concrete gap | Effort |
|---|---|---|
| W7.reports — Math snapshot tests | Zero coverage in tests/unit/services/reports/ or equivalent. Only PDF byte smoke tests + E2E clickthrough. Revenue/funnel/occupancy math has never been hand-verified. |
half-day |
| W7.recommender — Tier-ladder snapshots | Existing tests/unit/services/berth-recommender.test.ts has real edge-case coverage with plain toBe/toBeCloseTo assertions. Not snapshots — limits regression-catching for future weight tuning. |
2h |
Tier 4 — opportunistic / rolling
| Item | Concrete gap | Effort |
|---|---|---|
G.1 — .toLocale* migration |
Count grew 93 → 105 sites since audit (more added than migrated). Helper at format-date.ts is scaffolded but call sites haven't moved. |
rolling, ~3h opportunistic |
| G.2 — drizzle-zod adoption | 4 of 40 validator files (10%). Most validators have heavy form transforms that don't 1:1 with table shape — adoption is bounded. | rolling, 30min/file |
| W6.attach — Attachment threshold UX gating | Threshold logic + banner exist; IMAP-gating of the banner wasn't fully verified at the component level (CLAUDE.md claims it; not seen in send-dialog code). May be working — needs spot-check. | 15min spot-check; up to 1h if broken |
| D.1 — Per-user reminder schedule | TODO at scheduler.ts:48 intact. Flat hourly cron only. Defer until customer asks (no concrete trigger). |
deferred |
| D.2 — CSV/Excel import worker | Worker file exists + registered with BullMQ, but handler is a no-op TODO. No enqueue calls anywhere. Defer (no concrete trigger). | deferred |
Revised execution plan — supersedes Phases A-F
Tier 1 (5h total) → ship before next prod deploy. A.7 + A.8 + W2.14.
Tier 2 Documenso completion (8-10h, mostly optional polish): A.Phase 2 completion_cc_emails (2h) + A.Phase 4 anchor button wiring (3h) + C.2 recipient_email column (1h) + C.4 service extraction (2h, refactor). A.Phase 6 polish items defer until a customer needs them.
Tier 3 reporting/recommender validation (6h): Build snapshot fixtures for revenue/funnel/occupancy math + add snapshot variants of recommender tier-ladder tests.
Tier 4 rolling: G.1 and G.2 migrate opportunistically as files are touched. W6.attach 15-min spot-check. D.1 and D.2 stay deferred.
Total real outstanding work: ~19-25h (~3 focused days). Down from the original 4-week estimate.
Commit prefixes for this round
fix(audit-wave-1): A.7 RTBF true wipefix(audit-wave-1): A.8 user_permission_overrides set-null FKfix(audit-wave-2): W2.14 PII redaction camelCase coveragefeat(documenso-p2): completion_cc_emails distributionfeat(documenso-p4): anchor detector + auto-place buttonfeat(audit-c): documentEvents.recipient_email + per-recipient deduprefactor(audit-c): extract publicInterestServicetest(audit-w7): reporting math snapshot fixturestest(audit-w7): recommender tier-ladder snapshots
Verification team config (for reference / re-use)
Sweep was 5 agents (originally), Sonnet 1M with explicit model: param, narrow scope (4-5 items each), pre-quoted evidence where possible:
wave1-finisher— A.3/A.6/A.7/A.8wave2-finisher— W2.10/W2.11/W2.14/W2.15documenso-verifier— A.Phase 2/4/5/6/7 + Risk #4 + C.1/C.3misc-verifier— D.1/D.2 + E.1/E.2 + W3 + W6.bounce + W6.attach + W7.reports + W7.recommenderreverify-wave1-quickwins+reverify-wave2-and-c+reverify-phase3-and-g— re-did 13 items fresh (per user request to discard inline pre-digest)
Lessons captured in ~/.claude/projects/-Users-matt-Repos-new-pn-crm/memory/feedback_subagent_context_bloat.md.