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>
This commit is contained in:
2026-05-18 18:22:36 +02:00
parent ef0dc5abc4
commit b3f87563c6
25 changed files with 2569 additions and 350 deletions

View File

@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { formatDistanceToNow } from 'date-fns';
import { formatDate } from '@/lib/utils/format-date';
import { Download, History, Search, X } from 'lucide-react';
import { toast } from 'sonner';
@@ -247,7 +248,7 @@ export function AuditLogList() {
header: 'Time',
cell: ({ row }) => (
<div className="text-sm">
<div>{new Date(row.original.createdAt).toLocaleString()}</div>
<div>{formatDate(row.original.createdAt, 'datetime.medium')}</div>
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
</div>
@@ -644,7 +645,7 @@ export function AuditLogList() {
{detailEntry.action.replace(/_/g, ' ')} {detailEntry.entityType}
</SheetTitle>
<SheetDescription>
{new Date(detailEntry.createdAt).toLocaleString()}
{formatDate(detailEntry.createdAt, 'datetime.medium')}
{detailEntry.actor ? ` · ${detailEntry.actor.name}` : ''}
</SheetDescription>
</SheetHeader>

View File

@@ -3,7 +3,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Document, Page, pdfjs } from 'react-pdf';
import { ChevronLeft, ChevronRight, Eye, Loader2, Save, Trash2, Upload, X } from 'lucide-react';
import {
ChevronLeft,
ChevronRight,
Eye,
Loader2,
Save,
Sparkles,
Trash2,
Upload,
X,
} from 'lucide-react';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
@@ -130,6 +140,8 @@ function TemplateEditorBody({
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewInterestId, setPreviewInterestId] = useState<string>('');
const [autoDetectLoading, setAutoDetectLoading] = useState(false);
const [autoDetectMsg, setAutoDetectMsg] = useState<string | null>(null);
const pageContainerRef = useRef<HTMLDivElement | null>(null);
const outerColumnRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -338,6 +350,82 @@ function TemplateEditorBody({
}
}
// ─── Auto-detect anchors (Phase 4) ───────────────────────────────────────
// Calls the field-detector service via POST /detect-fields, which scans
// the current source PDF's text content for SIGNATURE / DATE / INITIALS /
// NAME / EMAIL anchors. Converts the returned DetectedField[] (percent
// 0..100) into FieldMapEntry markers (percent 0..1) and appends them to
// the existing field map. Users retag tokens via the per-marker dropdown.
async function autoDetect() {
setAutoDetectLoading(true);
setAutoDetectMsg(null);
try {
const res = await apiFetch<{
data: {
fields: Array<{
type: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
confidence: number;
anchorText: string;
inferredRecipientLabel?: string | null;
}>;
totalAnchors: number;
};
}>(`/api/v1/document-templates/${templateId}/detect-fields`, { method: 'POST' });
if (res.data.fields.length === 0) {
setAutoDetectMsg('No anchors detected. Place markers manually.');
return;
}
// Map detected type → best-guess merge token. Falls back to first
// sorted token when the detector finds a Documenso-only field
// (SIGNATURE / INITIALS) that has no direct merge-token equivalent
// in the in-app fill pipeline — the user retags from the dropdown.
const fallbackToken = TOKEN_OPTIONS[0] ?? '';
const pick = (candidates: string[]): string => {
for (const c of candidates) {
if (TOKEN_OPTIONS.includes(c)) return c;
}
return fallbackToken;
};
const tokenForType = (type: string): string => {
switch (type.toUpperCase()) {
case 'DATE':
return pick(['{{eoi.dateGenerated}}', '{{eoi.todayDate}}', '{{date}}']);
case 'NAME':
return pick(['{{client.fullName}}', '{{client.firstName}}']);
case 'EMAIL':
return pick(['{{client.email}}']);
default:
return fallbackToken;
}
};
const newMarkers: FieldMap = res.data.fields.map((f) => ({
token: tokenForType(f.type),
page: f.pageNumber,
x: f.pageX / 100,
y: f.pageY / 100,
w: Math.max(MIN_MARKER_DIM, f.pageWidth / 100),
h: Math.max(MIN_MARKER_DIM, f.pageHeight / 100),
}));
setMarkers((current) => [...current, ...newMarkers]);
setAutoDetectMsg(
`Added ${newMarkers.length} marker${newMarkers.length === 1 ? '' : 's'} from auto-detect. Review the assigned tokens (defaults are best-guess) and save.`,
);
} catch (err) {
toastError(err);
} finally {
setAutoDetectLoading(false);
}
}
// ─── New-PDF upload (replace source) ─────────────────────────────────────
async function onReplacePdf(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -417,6 +505,19 @@ function TemplateEditorBody({
{isDirty ? (
<span className="text-xs text-amber-700 font-medium">Unsaved changes</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={autoDetect}
disabled={autoDetectLoading || !pdfUrl}
>
{autoDetectLoading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
Auto-detect
</Button>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-1.5 h-3.5 w-3.5" /> Replace PDF
</Button>
@@ -436,6 +537,7 @@ function TemplateEditorBody({
/>
{savedMsg ? <p className="text-xs text-emerald-700">{savedMsg}</p> : null}
{autoDetectMsg ? <p className="text-xs text-sky-700">{autoDetectMsg}</p> : null}
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<div ref={outerColumnRef}>

View File

@@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge';
import { StatusPill } from '@/components/ui/status-pill';
import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants';
import { formatDate } from '@/lib/utils/format-date';
import { UserCard } from './user-card';
import { UserForm } from './user-form';
@@ -114,9 +115,7 @@ export function UserList() {
accessorKey: 'lastLoginAt',
header: 'Last Login',
cell: ({ row }) =>
row.original.lastLoginAt
? new Date(row.original.lastLoginAt).toLocaleDateString()
: 'Never',
row.original.lastLoginAt ? formatDate(row.original.lastLoginAt, 'date.medium') : 'Never',
},
{
id: 'actions',