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:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Phase 3 — Custom document upload-to-Documenso.
|
||||
* Phase 3 - Custom document upload-to-Documenso.
|
||||
*
|
||||
* The Contract + Reservation tabs upload a draft PDF, configure
|
||||
* recipients + fields, and hand the bundle to Documenso for signing.
|
||||
@@ -8,7 +8,7 @@
|
||||
* delegates here.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Magic-byte verify the PDF (defense vs. mislabelled bytes —
|
||||
* 1. Magic-byte verify the PDF (defense vs. mislabelled bytes -
|
||||
* same posture as berth-pdf + brochures).
|
||||
* 2. Insert a `files` row + push the PDF into storage. The row is
|
||||
* port-scoped + entity-scoped (interest) so it appears in the
|
||||
@@ -17,7 +17,7 @@
|
||||
* interest + the source file.
|
||||
* 4. Documenso round-trip: createDocument → placeFields → sendDocument.
|
||||
* Per-port apiVersion drives v1 vs v2 routing (existing client
|
||||
* handles both — v1: legacy /api/v1/documents; v2: envelope/create
|
||||
* handles both - v1: legacy /api/v1/documents; v2: envelope/create
|
||||
* multipart).
|
||||
* 5. Capture per-recipient signingUrl + token into `document_signers`
|
||||
* so the webhook cascade picks them up (Phase 2).
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
createDocument as documensoCreate,
|
||||
sendDocument as documensoSend,
|
||||
placeFields,
|
||||
voidDocument as documensoVoid,
|
||||
type DocumensoFieldPlacement,
|
||||
type DocumensoRecipient,
|
||||
} from '@/lib/services/documenso-client';
|
||||
@@ -61,12 +62,18 @@ import { advanceStageIfBehind } from '@/lib/services/interests.service';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/** Document types this service accepts. EOI is template-driven (uses
|
||||
* the dedicated EOI path); contract + reservation_agreement upload a
|
||||
* rep-supplied PDF and place fields per-deal. */
|
||||
export type CustomDocumentType = 'contract' | 'reservation_agreement';
|
||||
/** Document types this service accepts. EOI / contract /
|
||||
* reservation_agreement each follow the same upload-PDF +
|
||||
* place-fields + send-to-Documenso flow with per-type pipeline stage
|
||||
* + doc-status side effects. `'generic'` is the universal path -
|
||||
* used by the cross-cutting "any uploaded file can be a signing
|
||||
* envelope" feature: no pipeline advance, no doc-status flip, just a
|
||||
* files + documents row marked `sent`. The template-driven EOI
|
||||
* generation lives in `document-templates.ts` and follows a
|
||||
* different route. */
|
||||
export type CustomDocumentType = 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
|
||||
|
||||
/** Documenso recipient role — narrowed from the full enum to the
|
||||
/** Documenso recipient role - narrowed from the full enum to the
|
||||
* three values the custom-upload flow accepts. APPROVER + CC are
|
||||
* documented in plan Q4. VIEWER + ASSISTANT are out of scope for
|
||||
* marina contracts today. */
|
||||
@@ -89,11 +96,11 @@ export interface UploadDocumentForSigningArgs {
|
||||
filename: string;
|
||||
recipients: CustomDocumentRecipient[];
|
||||
/** Field placements come from Phase 4's drag-drop UI or auto-detect.
|
||||
* `recipientId` is the INDEX into `recipients` — the service maps
|
||||
* `recipientId` is the INDEX into `recipients` - the service maps
|
||||
* it to the resolved Documenso recipient id after createDocument
|
||||
* responds. */
|
||||
fields: Array<Omit<DocumensoFieldPlacement, 'recipientId'> & { recipientIndex: number }>;
|
||||
/** Phase 6 polish — optional rep-authored note inserted above the
|
||||
/** Phase 6 polish - optional rep-authored note inserted above the
|
||||
* CTA in every signing-invitation email for this document. Stored
|
||||
* on documents.invitation_message; falls back to the template
|
||||
* default when null/empty. */
|
||||
@@ -111,7 +118,7 @@ export interface UploadDocumentForSigningResult {
|
||||
}
|
||||
|
||||
const PDF_MIME = 'application/pdf';
|
||||
const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB — matches MAX_FILE_SIZE default
|
||||
const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB - matches MAX_FILE_SIZE default
|
||||
|
||||
export async function uploadDocumentForSigning(
|
||||
args: UploadDocumentForSigningArgs,
|
||||
@@ -148,7 +155,7 @@ export async function uploadDocumentForSigning(
|
||||
}
|
||||
// Every field's recipientIndex must reference a real recipient. Out-
|
||||
// of-range indexes silently maps to undefined in the recipient lookup
|
||||
// below — fail loudly here instead.
|
||||
// below - fail loudly here instead.
|
||||
for (const f of fields) {
|
||||
if (f.recipientIndex < 0 || f.recipientIndex >= recipients.length) {
|
||||
throw new ValidationError(
|
||||
@@ -181,9 +188,21 @@ export async function uploadDocumentForSigning(
|
||||
// the pre-signed draft in the Files tab. We also use the resolved
|
||||
// storage key as the `documents.fileId` reference.
|
||||
const sourceFileId = crypto.randomUUID();
|
||||
// Storage path category mirrors documentType so admins poking at
|
||||
// the bucket can tell at a glance what each blob is. Generic
|
||||
// envelopes land under `signed-source` (uploaded for signing but no
|
||||
// pipeline-stage context).
|
||||
const storageCategory =
|
||||
documentType === 'contract'
|
||||
? 'contract-source'
|
||||
: documentType === 'reservation_agreement'
|
||||
? 'reservation-source'
|
||||
: documentType === 'eoi'
|
||||
? 'eoi-source'
|
||||
: 'signed-source';
|
||||
const sourceStoragePath = buildStoragePath(
|
||||
portSlug,
|
||||
documentType === 'contract' ? 'contract-source' : 'reservation-source',
|
||||
storageCategory,
|
||||
interestId,
|
||||
sourceFileId,
|
||||
'pdf',
|
||||
@@ -206,7 +225,7 @@ export async function uploadDocumentForSigning(
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, interestId, clientId: interest.clientId },
|
||||
'ensureEntityFolder failed during custom-document-upload — filing at root',
|
||||
'ensureEntityFolder failed during custom-document-upload - filing at root',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +247,7 @@ export async function uploadDocumentForSigning(
|
||||
})
|
||||
.returning();
|
||||
if (!sourceFileRecord) {
|
||||
// Best-effort compensating delete — we put a blob but the DB row
|
||||
// Best-effort compensating delete - we put a blob but the DB row
|
||||
// failed to land, leaving an orphan otherwise.
|
||||
await storage.delete(sourceStoragePath).catch(() => {});
|
||||
throw new ConflictError('Failed to record source file');
|
||||
@@ -282,16 +301,44 @@ export async function uploadDocumentForSigning(
|
||||
signingOrder: r.signingOrder,
|
||||
}));
|
||||
|
||||
const documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
|
||||
});
|
||||
// Documenso round-trip wrapped in try/catch so a failed
|
||||
// create/send/placeFields call doesn't leave a phantom `draft` row
|
||||
// sitting at the top of the Reservation/Contract tab forever. On
|
||||
// failure we mark the local row `cancelled` and (best-effort) void
|
||||
// any envelope we already minted upstream, then re-throw - caller
|
||||
// sees the same DOCUMENSO_UPSTREAM_ERROR as before, but the
|
||||
// dashboard state stays clean. Previously, repeated send failures
|
||||
// accumulated abandoned drafts that masked the rep's real working
|
||||
// document.
|
||||
let documensoDoc: Awaited<ReturnType<typeof documensoCreate>>;
|
||||
let sentDoc: Awaited<ReturnType<typeof documensoSend>>;
|
||||
try {
|
||||
documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Map our recipientIndex → resolved Documenso recipient id (number/
|
||||
// string). On v2 the envelope/create response doesn't include
|
||||
// recipient ids; we resolve via the distribute response below
|
||||
// (sendDocument returns the full doc with recipients).
|
||||
const sentDoc = await documensoSend(documensoDoc.id, portId);
|
||||
try {
|
||||
sentDoc = await documensoSend(documensoDoc.id, portId);
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
await documensoVoidSafe(documensoDoc.id, portId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Build email→recipientId map. v2 envelope create returns empty
|
||||
// recipients; distribute fills them in. v1 already has them on create.
|
||||
@@ -300,13 +347,13 @@ export async function uploadDocumentForSigning(
|
||||
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
|
||||
}
|
||||
|
||||
// Place fields (skipped silently when empty — but we validated above).
|
||||
// Place fields (skipped silently when empty - but we validated above).
|
||||
const placements: DocumensoFieldPlacement[] = fields.map((f) => {
|
||||
const recipient = recipients[f.recipientIndex]!;
|
||||
const recipientId = emailToRecipientId.get(recipient.email.toLowerCase());
|
||||
if (!recipientId) {
|
||||
throw new ConflictError(
|
||||
`Documenso response missing recipientId for ${recipient.email} — cannot place fields`,
|
||||
`Documenso response missing recipientId for ${recipient.email} - cannot place fields`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -320,7 +367,16 @@ export async function uploadDocumentForSigning(
|
||||
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
||||
};
|
||||
});
|
||||
await placeFields(documensoDoc.id, placements, portId);
|
||||
try {
|
||||
await placeFields(documensoDoc.id, placements, portId);
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
await documensoVoidSafe(documensoDoc.id, portId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Update local signers with signingUrl + token from Documenso.
|
||||
const signingUrls: Record<string, string> = {};
|
||||
@@ -345,28 +401,49 @@ export async function uploadDocumentForSigning(
|
||||
.set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
|
||||
// Pipeline transition: contract / reservation custom-upload goes out
|
||||
// for signing. Stamps the matching doc-status sub-state so the badge
|
||||
// flips to 'sent' immediately. EOI stage is reserved for the template
|
||||
// pathway and stamped from documents.service.ts. No berth-rules trigger
|
||||
// here — the rules engine fires on `contract_signed` (webhook-driven).
|
||||
const targetStage = documentType === 'contract' ? 'contract' : 'reservation';
|
||||
void advanceStageIfBehind(
|
||||
interestId,
|
||||
portId,
|
||||
targetStage,
|
||||
meta,
|
||||
`${documentType === 'contract' ? 'Contract' : 'Reservation agreement'} sent for signing`,
|
||||
);
|
||||
await db
|
||||
.update(interests)
|
||||
.set({
|
||||
...(documentType === 'contract'
|
||||
? { contractDocStatus: 'sent', dateContractSent: new Date() }
|
||||
: { reservationDocStatus: 'sent' }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, interestId));
|
||||
// Pipeline transition: any of the three doc types going out for
|
||||
// signing advances the matching pipeline stage + flips the type's
|
||||
// doc-status sub-state to 'sent' so the badge updates immediately.
|
||||
// EOI here is the upload-draft path (parity with contract/reservation
|
||||
// post-2026-05-22); the template-driven EOI flow stamps from
|
||||
// documents.service.ts. No berth-rules trigger here - the rules
|
||||
// engine fires on `contract_signed` etc. via the webhook handler.
|
||||
// `'generic'` documents skip the pipeline-stage advance + the
|
||||
// per-type doc-status flip - they're cross-cutting envelopes that
|
||||
// happen to be filed against this interest. The eoi / contract /
|
||||
// reservation_agreement branches keep their existing side effects.
|
||||
if (documentType !== 'generic') {
|
||||
const stageByType: Record<
|
||||
Exclude<CustomDocumentType, 'generic'>,
|
||||
'eoi' | 'contract' | 'reservation'
|
||||
> = {
|
||||
eoi: 'eoi',
|
||||
contract: 'contract',
|
||||
reservation_agreement: 'reservation',
|
||||
};
|
||||
const labelByType: Record<Exclude<CustomDocumentType, 'generic'>, string> = {
|
||||
eoi: 'EOI',
|
||||
contract: 'Contract',
|
||||
reservation_agreement: 'Reservation agreement',
|
||||
};
|
||||
void advanceStageIfBehind(
|
||||
interestId,
|
||||
portId,
|
||||
stageByType[documentType],
|
||||
meta,
|
||||
`${labelByType[documentType]} sent for signing`,
|
||||
);
|
||||
const interestPatch =
|
||||
documentType === 'contract'
|
||||
? { contractDocStatus: 'sent' as const, dateContractSent: new Date() }
|
||||
: documentType === 'reservation_agreement'
|
||||
? { reservationDocStatus: 'sent' as const }
|
||||
: { eoiDocStatus: 'sent' as const, dateEoiSent: new Date() };
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ ...interestPatch, updatedAt: new Date() })
|
||||
.where(eq(interests.id, interestId));
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
@@ -434,11 +511,11 @@ export async function uploadDocumentForSigning(
|
||||
* Map Documenso's recipient role enum to our internal signerRole
|
||||
* vocabulary (`client | developer | approver | witness | other`).
|
||||
*
|
||||
* The custom-upload flow doesn't know which role label fits — the rep
|
||||
* The custom-upload flow doesn't know which role label fits - the rep
|
||||
* picks SIGNER/APPROVER/CC in the dialog. We map SIGNER → 'other' (the
|
||||
* generic case; matches the email template's neutral copy) UNLESS the
|
||||
* recipient is the first signer in order, in which case the dialog
|
||||
* defaults to the client (handled at the UI level in Phase 4 — the
|
||||
* defaults to the client (handled at the UI level in Phase 4 - the
|
||||
* service stays role-blind).
|
||||
*/
|
||||
function documensoRoleToLocal(role: CustomRecipientRole): SignerRole {
|
||||
@@ -461,6 +538,21 @@ export type { DocumensoFieldPlacement } from '@/lib/services/documenso-client';
|
||||
// only indirectly via downstream type inference.
|
||||
export type { CustomDocumentType as _CustomDocumentType };
|
||||
|
||||
// Keep the clients import referenced — used by future enhancements
|
||||
// Keep the clients import referenced - used by future enhancements
|
||||
// that resolve the client name for default recipient prefill.
|
||||
void clients;
|
||||
|
||||
/** Void an envelope upstream when we're rolling back a failed local
|
||||
* insert, swallowing any further upstream error (we've already lost
|
||||
* the original failure and don't want to mask it with a cleanup
|
||||
* exception). */
|
||||
async function documensoVoidSafe(documensoId: string, portId: string): Promise<void> {
|
||||
try {
|
||||
await documensoVoid(documensoId, portId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, documensoId, portId },
|
||||
'Failed to void Documenso envelope during rollback - admin can clean up manually',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user