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:
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user