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:
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user