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