chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -83,7 +83,7 @@ function buildHubTabFilters(
switch (tab) {
case 'in_progress':
// All document types currently in-flight the everyday "what's in flight" view.
// All document types currently in-flight - the everyday "what's in flight" view.
filters.push(
inArray(documents.status, ['draft', 'sent', 'partially_signed']),
sql`${documents.status} != 'expired'`,
@@ -204,7 +204,7 @@ export async function listDocuments(
} else {
filters.push(eq(documents.folderId, query.folderId));
}
// When viewing a specific folder, hide completed workflows they surface
// When viewing a specific folder, hide completed workflows - they surface
// via their resulting signed-PDF file row in the Files section, not the
// Signing section.
filters.push(ne(documents.status, 'completed'));
@@ -272,7 +272,7 @@ export async function listDocuments(
* Resolve the rep-facing download URL for a document. The URL embeds the
* folder path + filename for browser-tab / shared-link readability, but the
* route handler keys lookup off the doc id and validates the slug for truth
* a hand-edited URL with the wrong path 404s instead of silently serving
* - a hand-edited URL with the wrong path 404s instead of silently serving
* the wrong file.
*
* Pass the resolved folder tree once per request and call this for each doc
@@ -297,7 +297,7 @@ export function buildDocumentDownloadUrl(
/**
* Walk the folder tree to materialize the ancestor chain that ends at
* `id`. Returns roots-first; empty when the id is missing (orphan
* `folder_id` pointer see listTree's intentional silent drop).
* `folder_id` pointer - see listTree's intentional silent drop).
*/
export function findFolderPath(tree: readonly FolderNode[], id: string): FolderNode[] {
for (const node of tree) {
@@ -623,7 +623,7 @@ export async function updateDocument(
* accepting signatures and outstanding signing URLs invalidate).
*
* Refuses to delete a document in the middle of signing (`sent` /
* `partially_signed`) reps must cancel first, then delete the
* `partially_signed`) - reps must cancel first, then delete the
* cancelled record.
*/
export async function deleteDocument(id: string, portId: string, meta: AuditMeta) {
@@ -631,7 +631,7 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
if (['sent', 'partially_signed'].includes(existing.status)) {
throw new ConflictError(
'Cannot delete a document while signing is in progress cancel it first, then delete the cancelled record.',
'Cannot delete a document while signing is in progress - cancel it first, then delete the cancelled record.',
);
}
if (existing.status === 'deleted') {
@@ -640,7 +640,7 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
}
// Best-effort upstream void. A transient Documenso failure shouldn't
// block the CRM-side delete the document_events row + audit log
// block the CRM-side delete - the document_events row + audit log
// capture what happened, and `voidDocument` treats 404 (already gone)
// as success so a Documenso UI re-delete remains safe.
if (existing.documensoId) {
@@ -765,7 +765,7 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
const pdfBase64 = pdfBuffer.toString('base64');
// Read per-port v2 signing settings (PARALLEL/SEQUENTIAL + redirect URL).
// Both are optional passing undefined yields v1's legacy behavior.
// Both are optional - passing undefined yields v1's legacy behavior.
const docCfg = await getPortDocumensoConfig(portId);
// Create document in Documenso + send. portId is required for the v2
@@ -800,7 +800,7 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
// Update signer records with signing URLs + tokens from the Documenso
// response. The signingToken column powers the webhook recipient-match
// path (more robust than email match same email can serve multiple
// path (more robust than email match - same email can serve multiple
// roles on a contract). Documenso's recipient response carries `token`
// explicitly per the OpenAPI spec; we keep the URL-extraction fallback
// for any v2 deployment whose distribute response omits the field.
@@ -969,7 +969,7 @@ export async function uploadSignedManually(
if (interest) {
void evaluateRule('eoi_signed', doc.interestId, portId, meta);
// Stage stays at 'eoi' sub-status badge flips to "signed".
// Stage stays at 'eoi' - sub-status badge flips to "signed".
void advanceStageIfBehind(
doc.interestId,
portId,
@@ -1043,8 +1043,8 @@ export async function listDocumentEvents(documentId: string, portId: string) {
/**
* Shared port-scoped lookup for inbound Documenso webhooks. Two ports
* sharing a Documenso instance or migrating between instances with
* documentId reuse would otherwise let `findFirst` return whichever
* sharing a Documenso instance - or migrating between instances with
* documentId reuse - would otherwise let `findFirst` return whichever
* row sorts first across tenants. When the route resolves a portId from
* the matched per-port webhook secret it threads it here; otherwise we
* fall back to a port-agnostic `findMany` and refuse to mutate when the
@@ -1089,7 +1089,7 @@ async function resolveWebhookDocument(
if (matches.length > 1) {
logger.error(
{ documensoId, matchCount: matches.length, ports: matches.map((m) => m.portId) },
'Documenso webhook ambiguous across multiple ports refusing to mutate',
'Documenso webhook ambiguous across multiple ports - refusing to mutate',
);
return null;
}
@@ -1099,7 +1099,7 @@ async function resolveWebhookDocument(
export async function handleRecipientSigned(eventData: {
documentId: string;
recipientEmail: string;
/** Optional Documenso recipient token when supplied (webhook
/** Optional Documenso recipient token - when supplied (webhook
* payload exposes it on v1.13 + 2.x), preferred over the email
* match because a single email can serve multiple roles on one
* document. Falls back to email match when null. */
@@ -1112,7 +1112,7 @@ export async function handleRecipientSigned(eventData: {
// Token-match first, fall back to email match. Phase 2: webhook
// payloads carry `recipients[].token` which we captured at send-time
// via extractSigningToken that's the authoritative identifier.
// via extractSigningToken - that's the authoritative identifier.
const signerWhere = eventData.recipientToken
? and(
eq(documentSigners.documentId, doc.id),
@@ -1126,7 +1126,7 @@ export async function handleRecipientSigned(eventData: {
// Read prior status so we know whether this delivery is the first
// signing transition. Documenso v2 retries deliver the same
// DOCUMENT_RECIPIENT_COMPLETED multiple times with slightly different
// rawBody hashes without this gate the cascade fires on every
// rawBody hashes - without this gate the cascade fires on every
// delivery, the "your turn" email goes out twice, and downstream side
// effects (rule engine, audit, notifications) duplicate.
const [priorSigner] = await db.select().from(documentSigners).where(signerWhere);
@@ -1137,7 +1137,7 @@ export async function handleRecipientSigned(eventData: {
.set({
status: 'signed',
// Preserve the original signedAt timestamp on duplicate webhook
// deliveries overwriting it makes every signer card show the
// deliveries - overwriting it makes every signer card show the
// most-recent webhook timestamp instead of the actual sign time.
...(wasAlreadySigned ? {} : { signedAt: new Date() }),
})
@@ -1195,12 +1195,12 @@ export async function handleRecipientSigned(eventData: {
// Phase 2 cascade: now that this signer is done, fire the branded
// "your turn" invitation to the next pending signer in signing order.
// Skip the cascade entirely on duplicate deliveries only fire on
// Skip the cascade entirely on duplicate deliveries - only fire on
// the first pending→signed transition. The `invitedAt` guard inside
// sendCascadingInviteForNextSigner is a second safety net.
if (signer && !wasAlreadySigned) {
await sendCascadingInviteForNextSigner(doc).catch((err) => {
// Cascading-invite failure is non-fatal the webhook itself
// Cascading-invite failure is non-fatal - the webhook itself
// succeeded. The rep can manually click "Send invitation" if the
// email worker is down.
logger.error(
@@ -1212,7 +1212,7 @@ export async function handleRecipientSigned(eventData: {
}
/**
* Phase 2 cascading invite logic extracted so the
* Phase 2 - cascading invite logic extracted so the
* `handleRecipientSigned` handler stays readable and so the same path
* can be exercised by a dedicated unit test. Finds the next pending
* signer (lowest signing order), sends them a branded invitation, and
@@ -1238,7 +1238,7 @@ async function sendCascadingInviteForNextSigner(doc: {
const next = nextPendingSigner(signers);
if (!next) return;
if (next.invitedAt) {
// We've already invited them either via the auto-send wiring at
// We've already invited them - either via the auto-send wiring at
// document creation (first signer) or via an earlier cascade. Do
// nothing rather than spam them with a second copy.
return;
@@ -1270,7 +1270,7 @@ async function sendCascadingInviteForNextSigner(doc: {
.set({ invitedAt: new Date() })
.where(eq(documentSigners.id, next.id));
// Phase 7 Project Director RBAC binding: when the per-port settings
// Phase 7 - Project Director RBAC binding: when the per-port settings
// map the developer / approver slot to a CRM user (developerUserId /
// approverUserId), fire an in-CRM notification so the user sees their
// pending signing turn alongside the branded email. The email is the
@@ -1313,7 +1313,7 @@ interface ResolvedOwner {
}
/**
* Owner-wins owner resolution chain see spec §"Routing on workflow
* Owner-wins owner resolution chain - see spec §"Routing on workflow
* completion" §3a. Returns the first non-null candidate in priority
* order: direct client/company/yacht FK on the document, then via the
* linked interest's client / yacht FK. The interests table has no
@@ -1334,7 +1334,7 @@ async function resolveDocumentOwner(
if (doc.yachtId) return { entityType: 'yacht', entityId: doc.yachtId };
if (doc.interestId) {
// interests.clientId is NOT NULL if the interest row exists, the
// interests.clientId is NOT NULL - if the interest row exists, the
// client owner is always resolvable through it. The yacht-only path
// would require relaxing the schema constraint first.
const interest = await db.query.interests.findFirst({
@@ -1368,7 +1368,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
// A1: Idempotency gate. Documenso retries DOCUMENT_COMPLETED on receiver
// 5xx (and the poll worker also reconciles). Without this guard, a second
// delivery re-runs downloadSignedPdf + storage.put + db.insert(files) and
// then clobbers the previous signedFileId on the UPDATE leaking the
// then clobbers the previous signedFileId on the UPDATE - leaking the
// first file as an orphan blob with no DB pointer. Once we have a signed
// file id we are done.
if (doc.status === 'completed' && doc.signedFileId) return;
@@ -1388,7 +1388,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
try {
// Download by the stored Documenso ID (envelope_xxx on v2, numeric on
// v1) rather than `eventData.documentId` webhooks deliver the v2
// v1) rather than `eventData.documentId` - webhooks deliver the v2
// numeric internal pk, but the download endpoint expects the public
// envelope_xxx string. Falls back to the webhook's value when the
// stored ID is somehow missing (e.g. legacy pre-#69 rows).
@@ -1430,7 +1430,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
);
entityFolderId = folder.id;
} catch (err) {
// Folder creation is best-effort signed file still lands at root.
// Folder creation is best-effort - signed file still lands at root.
// Logged at warn level: missing entity folder is recoverable via
// the backfill script.
logger.warn(
@@ -1459,7 +1459,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
.for('update');
if (locked && locked.status === 'completed' && locked.signedFileId) {
// Concurrent webhook beat us abort so the outer catch deletes
// Concurrent webhook beat us - abort so the outer catch deletes
// the duplicate blob we just put into storage. Throw a sentinel
// we recognize so we don't log it as an error.
throw new DocumentAlreadyCompletedError();
@@ -1531,13 +1531,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
userAgent: 'webhook',
});
} catch (err) {
// Distinguish "we lost the concurrent race" from a real failure
// Distinguish "we lost the concurrent race" from a real failure -
// the loser of the SELECT FOR UPDATE re-check should clean up its
// blob silently, not log an error.
if (err instanceof DocumentAlreadyCompletedError) {
logger.info(
{ documentId: doc.id, portId: doc.portId },
'Webhook race lost another worker already committed the signed PDF; deleting our duplicate blob',
'Webhook race lost - another worker already committed the signed PDF; deleting our duplicate blob',
);
} else {
logger.error(
@@ -1556,16 +1556,16 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
'Compensating storage.delete after failed signed-PDF commit',
);
} catch (compErr) {
// We tried log so a human can clean up the orphan if needed.
// We tried - log so a human can clean up the orphan if needed.
logger.error(
{ compErr, documentId: doc.id, storagePath: putStoragePath },
'Compensating storage.delete also failed manual cleanup required',
'Compensating storage.delete also failed - manual cleanup required',
);
}
}
// Critical: do NOT set documents.status = 'completed' on failure.
// The previous catch block did which created the "completed-with-
// The previous catch block did - which created the "completed-with-
// no-signedFileId" zombie state the audit flagged. Let the next
// Documenso retry (or our poll-worker reconciliation) re-attempt;
// the early-return idempotency gate at the top requires BOTH
@@ -1597,7 +1597,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
};
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple
// times. evaluateRule has no idempotency skip when the interest is
// times. evaluateRule has no idempotency - skip when the interest is
// already past the EOI stage so the berth-rule side effect runs once.
const currentStageIdx = PIPELINE_STAGES.indexOf(
interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
@@ -1621,7 +1621,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
'eoi_signed',
);
// Phase 7 Umami attribution. EOI signed is the headline
// Phase 7 - Umami attribution. EOI signed is the headline
// conversion event so it gets its own Umami event for funnel
// visibility (rather than rolling up into "interest-stage-changed").
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
@@ -1633,7 +1633,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
}
}
// Update interest if reservation_agreement type kept out of the
// Update interest if reservation_agreement type - kept out of the
// signed-PDF try/catch above so a Documenso PDF-download failure doesn't
// also lose the sub-status stamp (which the rep can see immediately on
// the interest detail page).
@@ -1707,7 +1707,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
// Phase 2: distribute the fully-signed PDF to every recipient via a
// branded "all signed" email. Re-read the document so we see the
// signedFileId the transaction above just committed + the
// completionCcEmails list (Phase 2 sales mgr / accounts etc who get
// completionCcEmails list (Phase 2 - sales mgr / accounts etc who get
// a copy without being a signer).
const completedDoc = await db.query.documents.findFirst({
where: eq(documents.id, doc.id),
@@ -1722,7 +1722,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
.from(documentSigners)
.where(eq(documentSigners.documentId, doc.id));
// Phase 2 CC list emails that weren't signers but get a copy of
// Phase 2 CC list - emails that weren't signers but get a copy of
// the finalized PDF on completion. Filter to addresses not already
// in the signer set (case-insensitive) so a sales mgr who's also
// a signer doesn't get two emails.
@@ -1741,7 +1741,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
columns: { name: true },
});
// Resolve the deal's primary client name for the salutation
// Resolve the deal's primary client name for the salutation -
// falls back to the document title when the owner chain doesn't
// surface a client.
let clientName = doc.title;
@@ -1764,7 +1764,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
signedPdfFileId: completedDoc.signedFileId,
signedPdfFilename: `signed-${doc.id}.pdf`,
}).catch((err) => {
// Don't let a downstream email failure undo the completion
// Don't let a downstream email failure undo the completion -
// the signed PDF is already stored and the document row is
// marked completed. Log + emit so admins can re-trigger via
// the manual "Send copy" flow.
@@ -1822,7 +1822,7 @@ export async function handleDocumentExpired(eventData: { documentId: string; por
export async function handleDocumentOpened(eventData: {
documentId: string;
recipientEmail: string;
/** Optional Documenso recipient token preferred over email match
/** Optional Documenso recipient token - preferred over email match
* (same email may serve multiple roles on one document). */
recipientToken?: string | null;
signatureHash?: string;
@@ -1926,7 +1926,7 @@ export async function handleDocumentRejected(eventData: {
// 1. Notify the interest's assigned rep in-CRM (drives the EOI tab
// banner via the realtime invalidation + the bell).
// 2. Audit-log so the timeline surfaces the rejection.
// Email cascade to the other signers is intentionally NOT fired
// Email cascade to the other signers is intentionally NOT fired -
// the legal flow is "this EOI is dead, regenerate"; messaging the
// co-signers would create noise. The rep handles outreach manually.
if (doc.interestId) {
@@ -1943,8 +1943,8 @@ export async function handleDocumentRejected(eventData: {
type: 'document_rejected',
title: 'EOI declined',
description: eventData.recipientEmail
? `${eventData.recipientEmail} declined to sign review and regenerate.`
: 'A signer declined the EOI review and regenerate.',
? `${eventData.recipientEmail} declined to sign - review and regenerate.`
: 'A signer declined the EOI - review and regenerate.',
link: `/interests/${doc.interestId}?tab=eoi`,
entityType: 'document',
entityId: doc.id,
@@ -2017,7 +2017,7 @@ export interface DocumentDetailWatcher {
/**
* #67 linked-entity resolution: resolve each polymorphic FK on the
* document to a human-readable name so the doc-detail "Linked entity"
* card can render "Interest Matt Ciaccio" instead of "Interest →".
* card can render "Interest - Matt Ciaccio" instead of "Interest →".
* Each side is null when the FK is null or the row was deleted.
*/
export interface DocumentDetailLinkedEntities {
@@ -2158,6 +2158,16 @@ export interface CancelDocumentOptions {
* Empty list = silent void (Regenerate flow). Each id is validated to
* belong to this document before any email fires. */
notifyRecipients?: string[];
/**
* How to handle the upstream Documenso envelope. `'delete'` (the
* default) fires `DELETE /api/v2/envelope/{id}` so the envelope is
* removed from the Documenso instance - useful for keeping the
* Documenso log clean when drafts get abandoned. `'keep_remote'`
* leaves the envelope intact; the local CRM row still flips to
* `status='cancelled'` and the cancelled-doc badge surfaces the
* "Kept on Documenso" variant so audit-trail expectations are met.
*/
cancelMode?: 'delete' | 'keep_remote';
}
export async function cancelDocument(
@@ -2172,11 +2182,15 @@ export async function cancelDocument(
throw new ConflictError(`Document is already ${existing.status}`);
}
const cancelMode = options.cancelMode ?? 'delete';
// CRM is the system of record for cancellation status. A transient
// Documenso failure shouldn't block the user from marking the doc cancelled
// here - voidDocument already treats 404 as success, and the periodic
// webhook receiver will reconcile if the remote void eventually lands.
if (existing.documensoId) {
// `cancelMode='keep_remote'` skips the upstream DELETE entirely so the
// envelope stays available in Documenso for audit/forensics.
if (existing.documensoId && cancelMode === 'delete') {
try {
await documensoVoid(existing.documensoId, portId);
} catch (err) {
@@ -2200,6 +2214,7 @@ export async function cancelDocument(
initiatedBy: meta.userId,
reason: options.reason ?? null,
notifyCount: options.notifyRecipients?.length ?? 0,
cancelMode,
},
});
@@ -2210,7 +2225,7 @@ export async function cancelDocument(
entityType: 'document',
entityId: documentId,
oldValue: { status: existing.status },
newValue: { status: 'cancelled', reason: options.reason ?? null },
newValue: { status: 'cancelled', reason: options.reason ?? null, cancelMode },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -2220,7 +2235,7 @@ export async function cancelDocument(
// Notify selected signers (rep-picked subset via the cancel-with-notify
// modal). Pull the matching signer rows so we can render the recipient's
// canonical name; skip silently when the rep passed no ids (Regenerate
// flow). Failure to send is logged + non-fatal the cancellation has
// flow). Failure to send is logged + non-fatal - the cancellation has
// already committed locally.
const notifyIds = options.notifyRecipients ?? [];
if (notifyIds.length > 0) {
@@ -2586,7 +2601,7 @@ const INFLIGHT_STATUSES = ['draft', 'sent', 'partially_signed'] as const;
/**
* Same projection shape as listFilesAggregatedByEntity but for in-flight
* signing workflows. Completed/expired/cancelled workflows are hidden
* signing workflows. Completed/expired/cancelled workflows are hidden -
* they surface via their signed-PDF file row.
*/
export async function listInflightWorkflowsAggregatedByEntity(
@@ -2607,7 +2622,7 @@ export async function listInflightWorkflowsAggregatedByEntity(
? documents.companyId
: documents.yachtId;
// Batch the related-entity workflow lookups in parallel the
// Batch the related-entity workflow lookups in parallel - the
// pre-2026-05-14 sequential loop fired ~50 queries on a busy client
// (direct + each company + each yacht + each related client), each
// round-trip blocking the next. Now every lookup runs concurrently