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

@@ -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',
);
}
}