fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish

Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:28:50 +02:00
parent 397dbd1490
commit 4b5f85cb7d
158 changed files with 12255 additions and 1303 deletions

View File

@@ -17,7 +17,7 @@ import { getPortEmailConfig, type PortEmailConfig } from '@/lib/services/port-co
// worker concurrency slot for up to 2 min × 5 retry attempts = 10 min
// per job. With concurrency 5, all slots can be starved by a single
// flaky upstream. Explicit timeouts cap the worst case under a minute.
const SMTP_TIMEOUTS = {
export const SMTP_TIMEOUTS = {
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 30_000,
@@ -123,6 +123,11 @@ export async function sendEmail(
text?: string,
portId?: string,
attachments?: EmailAttachmentRef[],
// M-EM02: optional CC / BCC. Mirror the same EMAIL_REDIRECT_TO scrub
// as `to` so dev-mode redirects don't accidentally leak a CC outside
// the safety net.
cc?: string | string[],
bcc?: string | string[],
): Promise<nodemailer.SentMessageInfo> {
const cfg = portId ? await getPortEmailConfig(portId) : null;
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
@@ -132,6 +137,11 @@ export async function sendEmail(
const effectiveSubject = env.EMAIL_REDIRECT_TO
? `[redirected from ${requestedTo}] ${subject}`
: subject;
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO — the redirect target
// already gets the message; CCing additional recipients would defeat
// the dev safety net.
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : cc;
const effectiveBcc = env.EMAIL_REDIRECT_TO ? undefined : bcc;
const fromHeader =
from ??
@@ -148,6 +158,8 @@ export async function sendEmail(
html,
...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}),
...(text ? { text } : {}),
...(effectiveCc ? { cc: effectiveCc } : {}),
...(effectiveBcc ? { bcc: effectiveBcc } : {}),
...(resolvedAttachments.length > 0 ? { attachments: resolvedAttachments } : {}),
});

View File

@@ -22,6 +22,12 @@ export const TEMPLATE_KEYS = [
'inquiry_sales_notification',
'residential_inquiry_client_confirmation',
'residential_inquiry_sales_alert',
// M-EM04: daily notification digest. The digest service previously
// resolved its subject via `'crm_invite' as any` because no entry
// existed; making it a first-class key removes the cast and lets
// admins override the subject from /admin/email like every other
// template.
'notification_digest',
] as const;
export type TemplateKey = (typeof TEMPLATE_KEYS)[number];
@@ -95,6 +101,14 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
defaultSubject: 'New residential inquiry — {{clientName}}',
},
notification_digest: {
key: 'notification_digest',
label: 'Notification digest',
description:
"Daily roll-up of a rep's pending notifications. Fires from the digest worker; respects per-user opt-out.",
mergeTokens: ['portName', 'recipientName', 'unreadCount'],
defaultSubject: 'Your {{portName}} CRM digest — {{unreadCount}} updates',
},
};
/** system_settings key for a template's subject override. */

View File

@@ -325,3 +325,76 @@ export async function signingReminderEmail(
text,
};
}
// ─── 4. Cancelled ─────────────────────────────────────────────────────────────
interface CancelledData {
recipientName: string;
documentLabel: string;
portName: string;
/** Optional rep-authored reason. When null, the body explains the
* cancellation without speculation; when set, the reason renders in
* the same callout style as the invitation `customMessage`. */
reason?: string | null;
}
function CancelledBody({ data, accent }: { data: CancelledData; accent: string }) {
const greeting = `Dear ${data.recipientName},`;
return (
<>
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
{data.documentLabel} cancelled
</Text>
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
The {data.documentLabel} you were signing for {data.portName} has been cancelled. No further
action is required from you any signing link previously sent is no longer valid.
</Text>
{data.reason ? (
<Text
style={{
margin: '20px 0',
fontSize: '15px',
lineHeight: '1.6',
color: '#444',
padding: '14px 18px',
background: '#f8f9fb',
borderLeft: `3px solid ${accent}`,
borderRadius: '4px',
whiteSpace: 'pre-wrap',
}}
>
{data.reason}
</Text>
) : null}
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
If you have any questions, please reach out to your representative at {data.portName}.
</Text>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
<Text style={{ fontSize: '16px', marginTop: '24px' }}>
Thank you,
<br />
<strong>The {data.portName} team</strong>
</Text>
</>
);
}
export async function signingCancelledEmail(
data: CancelledData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const accent = brandingPrimaryColor(overrides?.branding);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
: `${data.documentLabel} cancelled — ${data.portName}`;
const body = await render(<CancelledBody data={data} accent={accent} />, { pretty: false });
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} you were signing for ${data.portName} has been cancelled. No further action is required.${data.reason ? '\n\nReason: ' + data.reason : ''}\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}