feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.
Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
full PDF → Documenso → local-state-update flow:
1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
same posture as berth-pdf + brochures).
2. Stores the source PDF via getStorageBackend(), works on s3 +
filesystem backends. Auto-files into the client's entity folder
when resolvable.
3. Inserts the documents row (status=draft → sent), with the file
FK + interest link + clientId snapshot.
4. Documenso round-trip via createDocument → sendDocument →
placeFields. Per-port apiVersion drives v1 vs v2 (existing
client handles both — v1: /api/v1/documents; v2: envelope/create
multipart). meta.signingOrder + redirectUrl flow through.
5. Captures recipient signingUrl + token into document_signers so
the Phase 2 cascade picks them up.
6. Auto-send first invitation when port.eoi_send_mode === 'auto';
stamps invitedAt to suppress duplicate cascades.
7. Advances pipeline stage to contract_sent.
- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
size (≤50MB), all 11 Documenso field types. Permission-gated by
documents.send_for_signing + interests.edit (matches the
external-eoi precedent — the auto-advance side-effect is
interest-mutating).
Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.
Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.
The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.
Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.
Tests: 1326 → 1334 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
* This service is the backend foundation; the UI dialog (Phase 4)
|
|
|
|
|
* eventually POSTs to /api/v1/interests/[id]/upload-for-signing which
|
|
|
|
|
* delegates here.
|
|
|
|
|
*
|
|
|
|
|
* Flow:
|
|
|
|
|
* 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
|
|
|
|
|
* Documents tab + the interest's entity folder.
|
|
|
|
|
* 3. Insert a `documents` row in `draft` status linked to the
|
|
|
|
|
* 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
|
|
|
|
|
* multipart).
|
|
|
|
|
* 5. Capture per-recipient signingUrl + token into `document_signers`
|
|
|
|
|
* so the webhook cascade picks them up (Phase 2).
|
|
|
|
|
* 6. If the port's `eoi_send_mode === 'auto'`, fire the branded
|
|
|
|
|
* invitation to the first signer immediately + stamp `invitedAt`.
|
|
|
|
|
* Manual mode leaves it to the rep's "Send invitation" button.
|
|
|
|
|
*
|
|
|
|
|
* Multi-tenant guard: the interest is read with both `id` AND `portId`
|
|
|
|
|
* filters; cross-port upload attempts return NotFoundError before any
|
|
|
|
|
* Documenso traffic.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { and, eq } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import { documents, documentSigners, files } from '@/lib/db/schema/documents';
|
|
|
|
|
import { interests } from '@/lib/db/schema/interests';
|
|
|
|
|
import { clients } from '@/lib/db/schema/clients';
|
|
|
|
|
import { ports } from '@/lib/db/schema/ports';
|
|
|
|
|
import { buildStoragePath } from '@/lib/minio';
|
|
|
|
|
import { env } from '@/lib/env';
|
|
|
|
|
import { getStorageBackend } from '@/lib/storage';
|
|
|
|
|
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
|
|
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|
|
|
|
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
|
|
|
|
|
import {
|
|
|
|
|
createDocument as documensoCreate,
|
|
|
|
|
sendDocument as documensoSend,
|
|
|
|
|
placeFields,
|
|
|
|
|
type DocumensoFieldPlacement,
|
|
|
|
|
type DocumensoRecipient,
|
|
|
|
|
} from '@/lib/services/documenso-client';
|
|
|
|
|
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
|
|
|
|
import {
|
|
|
|
|
sendSigningInvitation,
|
|
|
|
|
type SignerRole,
|
|
|
|
|
} from '@/lib/services/document-signing-emails.service';
|
|
|
|
|
import { DOC_TYPE_LABEL, extractSigningToken } from '@/lib/services/documenso-signers';
|
|
|
|
|
import { ensureEntityFolder } from '@/lib/services/document-folders.service';
|
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
/** 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. */
|
|
|
|
|
export type CustomRecipientRole = 'SIGNER' | 'APPROVER' | 'CC';
|
|
|
|
|
|
|
|
|
|
export interface CustomDocumentRecipient {
|
|
|
|
|
name: string;
|
|
|
|
|
email: string;
|
|
|
|
|
role: CustomRecipientRole;
|
|
|
|
|
signingOrder: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UploadDocumentForSigningArgs {
|
|
|
|
|
interestId: string;
|
|
|
|
|
portId: string;
|
|
|
|
|
portSlug: string;
|
|
|
|
|
documentType: CustomDocumentType;
|
|
|
|
|
title: string;
|
|
|
|
|
pdfBuffer: Buffer;
|
|
|
|
|
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
|
|
|
|
|
* it to the resolved Documenso recipient id after createDocument
|
|
|
|
|
* responds. */
|
|
|
|
|
fields: Array<Omit<DocumensoFieldPlacement, 'recipientId'> & { recipientIndex: number }>;
|
2026-05-13 14:17:39 +02:00
|
|
|
/** 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. */
|
|
|
|
|
invitationMessage?: string | null;
|
feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.
Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
full PDF → Documenso → local-state-update flow:
1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
same posture as berth-pdf + brochures).
2. Stores the source PDF via getStorageBackend(), works on s3 +
filesystem backends. Auto-files into the client's entity folder
when resolvable.
3. Inserts the documents row (status=draft → sent), with the file
FK + interest link + clientId snapshot.
4. Documenso round-trip via createDocument → sendDocument →
placeFields. Per-port apiVersion drives v1 vs v2 (existing
client handles both — v1: /api/v1/documents; v2: envelope/create
multipart). meta.signingOrder + redirectUrl flow through.
5. Captures recipient signingUrl + token into document_signers so
the Phase 2 cascade picks them up.
6. Auto-send first invitation when port.eoi_send_mode === 'auto';
stamps invitedAt to suppress duplicate cascades.
7. Advances pipeline stage to contract_sent.
- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
size (≤50MB), all 11 Documenso field types. Permission-gated by
documents.send_for_signing + interests.edit (matches the
external-eoi precedent — the auto-advance side-effect is
interest-mutating).
Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.
Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.
The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.
Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.
Tests: 1326 → 1334 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
|
|
|
meta: AuditMeta;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UploadDocumentForSigningResult {
|
|
|
|
|
documentId: string;
|
|
|
|
|
documensoDocumentId: string;
|
|
|
|
|
/** Map of recipient email → branded embedded signing URL. The UI
|
|
|
|
|
* exposes these so a rep can copy a link out for manual delivery in
|
|
|
|
|
* manual-send mode. */
|
|
|
|
|
signingUrls: Record<string, string>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PDF_MIME = 'application/pdf';
|
|
|
|
|
const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB — matches MAX_FILE_SIZE default
|
|
|
|
|
|
|
|
|
|
export async function uploadDocumentForSigning(
|
|
|
|
|
args: UploadDocumentForSigningArgs,
|
|
|
|
|
): Promise<UploadDocumentForSigningResult> {
|
|
|
|
|
const {
|
|
|
|
|
interestId,
|
|
|
|
|
portId,
|
|
|
|
|
portSlug,
|
|
|
|
|
documentType,
|
|
|
|
|
title,
|
|
|
|
|
pdfBuffer,
|
|
|
|
|
filename,
|
|
|
|
|
recipients,
|
|
|
|
|
fields,
|
2026-05-13 14:17:39 +02:00
|
|
|
invitationMessage,
|
feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.
Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
full PDF → Documenso → local-state-update flow:
1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
same posture as berth-pdf + brochures).
2. Stores the source PDF via getStorageBackend(), works on s3 +
filesystem backends. Auto-files into the client's entity folder
when resolvable.
3. Inserts the documents row (status=draft → sent), with the file
FK + interest link + clientId snapshot.
4. Documenso round-trip via createDocument → sendDocument →
placeFields. Per-port apiVersion drives v1 vs v2 (existing
client handles both — v1: /api/v1/documents; v2: envelope/create
multipart). meta.signingOrder + redirectUrl flow through.
5. Captures recipient signingUrl + token into document_signers so
the Phase 2 cascade picks them up.
6. Auto-send first invitation when port.eoi_send_mode === 'auto';
stamps invitedAt to suppress duplicate cascades.
7. Advances pipeline stage to contract_sent.
- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
size (≤50MB), all 11 Documenso field types. Permission-gated by
documents.send_for_signing + interests.edit (matches the
external-eoi precedent — the auto-advance side-effect is
interest-mutating).
Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.
Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.
The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.
Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.
Tests: 1326 → 1334 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
|
|
|
meta,
|
|
|
|
|
} = args;
|
|
|
|
|
|
|
|
|
|
// ─── Validation ──────────────────────────────────────────────────
|
|
|
|
|
if (recipients.length === 0) {
|
|
|
|
|
throw new ValidationError('At least one recipient is required');
|
|
|
|
|
}
|
|
|
|
|
if (fields.length === 0) {
|
|
|
|
|
throw new ValidationError('At least one field placement is required');
|
|
|
|
|
}
|
|
|
|
|
if (pdfBuffer.length === 0) {
|
|
|
|
|
throw new ValidationError('PDF buffer is empty');
|
|
|
|
|
}
|
|
|
|
|
if (pdfBuffer.length > MAX_PDF_BYTES) {
|
|
|
|
|
throw new ValidationError(`PDF exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
|
|
|
|
|
}
|
|
|
|
|
if (!isPdfMagic(pdfBuffer)) {
|
|
|
|
|
throw new ValidationError('Uploaded file is not a PDF (magic-byte check failed)');
|
|
|
|
|
}
|
|
|
|
|
// 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.
|
|
|
|
|
for (const f of fields) {
|
|
|
|
|
if (f.recipientIndex < 0 || f.recipientIndex >= recipients.length) {
|
|
|
|
|
throw new ValidationError(
|
|
|
|
|
`Field recipientIndex=${f.recipientIndex} is out of range (have ${recipients.length} recipients)`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Defense-in-depth: a duplicate signing-order would let Documenso
|
|
|
|
|
// accept the doc but break the cascading-invite logic (next signer
|
|
|
|
|
// picker assumes a strict ordering).
|
|
|
|
|
const orders = new Set<number>();
|
|
|
|
|
for (const r of recipients) {
|
|
|
|
|
if (orders.has(r.signingOrder)) {
|
|
|
|
|
throw new ValidationError(`Duplicate signingOrder=${r.signingOrder} in recipients`);
|
|
|
|
|
}
|
|
|
|
|
orders.add(r.signingOrder);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Tenant guard ────────────────────────────────────────────────
|
|
|
|
|
const interest = await db.query.interests.findFirst({
|
|
|
|
|
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!interest) throw new NotFoundError('Interest');
|
|
|
|
|
|
|
|
|
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
|
|
|
|
if (!port) throw new NotFoundError('Port');
|
|
|
|
|
|
|
|
|
|
// ─── Store source PDF ────────────────────────────────────────────
|
|
|
|
|
// The source PDF needs to live in storage so reps + admins can view
|
|
|
|
|
// the pre-signed draft in the Files tab. We also use the resolved
|
|
|
|
|
// storage key as the `documents.fileId` reference.
|
|
|
|
|
const sourceFileId = crypto.randomUUID();
|
|
|
|
|
const sourceStoragePath = buildStoragePath(
|
|
|
|
|
portSlug,
|
|
|
|
|
documentType === 'contract' ? 'contract-source' : 'reservation-source',
|
|
|
|
|
interestId,
|
|
|
|
|
sourceFileId,
|
|
|
|
|
'pdf',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const storage = await getStorageBackend();
|
|
|
|
|
await storage.put(sourceStoragePath, pdfBuffer, {
|
|
|
|
|
contentType: PDF_MIME,
|
|
|
|
|
sizeBytes: pdfBuffer.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Look up the interest's primary client so the auto-filed folder
|
|
|
|
|
// ends up under the right entity subfolder. Falls back to root when
|
|
|
|
|
// the chain has no resolvable owner.
|
|
|
|
|
let entityFolderId: string | null = null;
|
|
|
|
|
if (interest.clientId) {
|
|
|
|
|
try {
|
|
|
|
|
const folder = await ensureEntityFolder(portId, 'client', interest.clientId, 'system');
|
|
|
|
|
entityFolderId = folder.id;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.warn(
|
|
|
|
|
{ err, interestId, clientId: interest.clientId },
|
|
|
|
|
'ensureEntityFolder failed during custom-document-upload — filing at root',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [sourceFileRecord] = await db
|
|
|
|
|
.insert(files)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
clientId: interest.clientId,
|
|
|
|
|
folderId: entityFolderId,
|
|
|
|
|
filename,
|
|
|
|
|
originalName: filename,
|
|
|
|
|
mimeType: PDF_MIME,
|
|
|
|
|
sizeBytes: String(pdfBuffer.length),
|
|
|
|
|
storagePath: sourceStoragePath,
|
|
|
|
|
storageBucket: env.MINIO_BUCKET,
|
|
|
|
|
category: documentType,
|
|
|
|
|
uploadedBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
if (!sourceFileRecord) {
|
|
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Insert the document row (status=draft) ───────────────────────
|
|
|
|
|
const [docRow] = await db
|
|
|
|
|
.insert(documents)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
interestId,
|
|
|
|
|
clientId: interest.clientId,
|
|
|
|
|
fileId: sourceFileRecord.id,
|
|
|
|
|
documentType,
|
|
|
|
|
title,
|
|
|
|
|
status: 'draft',
|
2026-05-13 14:17:39 +02:00
|
|
|
invitationMessage: invitationMessage?.trim() || null,
|
feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.
Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
full PDF → Documenso → local-state-update flow:
1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
same posture as berth-pdf + brochures).
2. Stores the source PDF via getStorageBackend(), works on s3 +
filesystem backends. Auto-files into the client's entity folder
when resolvable.
3. Inserts the documents row (status=draft → sent), with the file
FK + interest link + clientId snapshot.
4. Documenso round-trip via createDocument → sendDocument →
placeFields. Per-port apiVersion drives v1 vs v2 (existing
client handles both — v1: /api/v1/documents; v2: envelope/create
multipart). meta.signingOrder + redirectUrl flow through.
5. Captures recipient signingUrl + token into document_signers so
the Phase 2 cascade picks them up.
6. Auto-send first invitation when port.eoi_send_mode === 'auto';
stamps invitedAt to suppress duplicate cascades.
7. Advances pipeline stage to contract_sent.
- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
size (≤50MB), all 11 Documenso field types. Permission-gated by
documents.send_for_signing + interests.edit (matches the
external-eoi precedent — the auto-advance side-effect is
interest-mutating).
Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.
Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.
The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.
Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.
Tests: 1326 → 1334 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
|
|
|
createdBy: meta.userId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
if (!docRow) throw new ConflictError('Failed to insert document row');
|
|
|
|
|
|
|
|
|
|
// ─── Local signer rows (pre-Documenso) ────────────────────────────
|
|
|
|
|
// Insert with status=pending; we'll fill signingUrl + signingToken
|
|
|
|
|
// after Documenso responds.
|
|
|
|
|
const signerRows = await db
|
|
|
|
|
.insert(documentSigners)
|
|
|
|
|
.values(
|
|
|
|
|
recipients.map((r) => ({
|
|
|
|
|
documentId: docRow.id,
|
|
|
|
|
signerName: r.name,
|
|
|
|
|
signerEmail: r.email,
|
|
|
|
|
// Map Documenso's enum back to our internal role taxonomy.
|
|
|
|
|
// APPROVER + CC both render with passive-recipient copy in our
|
|
|
|
|
// email templates.
|
|
|
|
|
signerRole: documensoRoleToLocal(r.role),
|
|
|
|
|
signingOrder: r.signingOrder,
|
|
|
|
|
status: 'pending' as const,
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
// ─── Documenso round-trip ────────────────────────────────────────
|
|
|
|
|
const docCfg = await getPortDocumensoConfig(portId);
|
|
|
|
|
const pdfBase64 = pdfBuffer.toString('base64');
|
|
|
|
|
|
|
|
|
|
const documensoRecipients: DocumensoRecipient[] = recipients.map((r) => ({
|
|
|
|
|
name: r.name,
|
|
|
|
|
email: r.email,
|
|
|
|
|
role: r.role,
|
|
|
|
|
signingOrder: r.signingOrder,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
|
|
|
|
|
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
|
|
|
|
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
// Build email→recipientId map. v2 envelope create returns empty
|
|
|
|
|
// recipients; distribute fills them in. v1 already has them on create.
|
|
|
|
|
const emailToRecipientId = new Map<string, string>();
|
|
|
|
|
for (const dr of sentDoc.recipients) {
|
|
|
|
|
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
recipientId,
|
|
|
|
|
type: f.type,
|
|
|
|
|
pageNumber: f.pageNumber,
|
|
|
|
|
pageX: f.pageX,
|
|
|
|
|
pageY: f.pageY,
|
|
|
|
|
pageWidth: f.pageWidth,
|
|
|
|
|
pageHeight: f.pageHeight,
|
|
|
|
|
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
await placeFields(documensoDoc.id, placements, portId);
|
|
|
|
|
|
|
|
|
|
// Update local signers with signingUrl + token from Documenso.
|
|
|
|
|
const signingUrls: Record<string, string> = {};
|
|
|
|
|
for (const dr of sentDoc.recipients) {
|
|
|
|
|
const local = signerRows.find((s) => s.signerEmail.toLowerCase() === dr.email?.toLowerCase());
|
|
|
|
|
if (!local) continue;
|
|
|
|
|
await db
|
|
|
|
|
.update(documentSigners)
|
|
|
|
|
.set({
|
|
|
|
|
signingUrl: dr.signingUrl ?? null,
|
|
|
|
|
embeddedUrl: dr.embeddedUrl ?? null,
|
|
|
|
|
signingToken: dr.token ?? extractSigningToken(dr.signingUrl ?? null),
|
|
|
|
|
})
|
|
|
|
|
.where(eq(documentSigners.id, local.id));
|
|
|
|
|
if (dr.signingUrl) signingUrls[dr.email] = dr.signingUrl;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Promote the local document to `sent` + record the Documenso id so
|
|
|
|
|
// the webhook handler can resolve subsequent events back to this row.
|
|
|
|
|
await db
|
|
|
|
|
.update(documents)
|
|
|
|
|
.set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
|
|
|
|
|
.where(eq(documents.id, docRow.id));
|
|
|
|
|
|
|
|
|
|
// Pipeline transition: contract_sent stage when contract or
|
|
|
|
|
// reservation_agreement goes out for signing. eoi_sent is reserved
|
|
|
|
|
// for the template-driven EOI flow. No berth-rules trigger here —
|
|
|
|
|
// the rules engine fires on `contract_signed` (webhook-driven).
|
|
|
|
|
void advanceStageIfBehind(
|
|
|
|
|
interestId,
|
|
|
|
|
portId,
|
|
|
|
|
'contract_sent',
|
|
|
|
|
meta,
|
|
|
|
|
`${documentType === 'contract' ? 'Contract' : 'Reservation agreement'} sent for signing`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: docRow.id,
|
|
|
|
|
newValue: {
|
|
|
|
|
documentType,
|
|
|
|
|
title,
|
|
|
|
|
documensoId: documensoDoc.id,
|
|
|
|
|
recipientCount: recipients.length,
|
|
|
|
|
fieldCount: fields.length,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'document:sent', {
|
|
|
|
|
documentId: docRow.id,
|
|
|
|
|
type: documentType,
|
|
|
|
|
signerCount: recipients.length,
|
|
|
|
|
documensoId: documensoDoc.id,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Auto-send first invitation ──────────────────────────────────
|
|
|
|
|
if (docCfg.sendMode === 'auto') {
|
|
|
|
|
const firstByOrder = [...signerRows].sort((a, b) => a.signingOrder - b.signingOrder)[0];
|
|
|
|
|
if (firstByOrder) {
|
|
|
|
|
// Re-read the row so we get the freshly-written signingUrl.
|
|
|
|
|
const refreshed = await db.query.documentSigners.findFirst({
|
|
|
|
|
where: eq(documentSigners.id, firstByOrder.id),
|
|
|
|
|
});
|
|
|
|
|
if (refreshed?.signingUrl) {
|
|
|
|
|
await sendSigningInvitation({
|
|
|
|
|
portId,
|
|
|
|
|
portName: port.name,
|
|
|
|
|
recipient: { name: refreshed.signerName, email: refreshed.signerEmail },
|
|
|
|
|
documensoSigningUrl: refreshed.signingUrl,
|
|
|
|
|
documentLabel: DOC_TYPE_LABEL[documentType] ?? 'Sales Contract',
|
|
|
|
|
signerRole: (refreshed.signerRole as SignerRole) ?? 'client',
|
|
|
|
|
senderName: docCfg.developerName ?? null,
|
2026-05-13 14:17:39 +02:00
|
|
|
customMessage: invitationMessage?.trim() || null,
|
feat(documenso-phase-3): custom document upload-to-Documenso
Backend foundation for the Contract + Reservation signing flows. The
existing tab placeholders point at a "send for signing" CTA that had
no code behind it; this commit lands the service + endpoint that the
Phase 4 drag-drop UI will POST to.
Files added:
- lib/services/custom-document-upload.service.ts — orchestrates the
full PDF → Documenso → local-state-update flow:
1. Magic-byte verifies the PDF (defense vs. mislabelled bytes —
same posture as berth-pdf + brochures).
2. Stores the source PDF via getStorageBackend(), works on s3 +
filesystem backends. Auto-files into the client's entity folder
when resolvable.
3. Inserts the documents row (status=draft → sent), with the file
FK + interest link + clientId snapshot.
4. Documenso round-trip via createDocument → sendDocument →
placeFields. Per-port apiVersion drives v1 vs v2 (existing
client handles both — v1: /api/v1/documents; v2: envelope/create
multipart). meta.signingOrder + redirectUrl flow through.
5. Captures recipient signingUrl + token into document_signers so
the Phase 2 cascade picks them up.
6. Auto-send first invitation when port.eoi_send_mode === 'auto';
stamps invitedAt to suppress duplicate cascades.
7. Advances pipeline stage to contract_sent.
- app/api/v1/interests/[id]/upload-for-signing/route.ts — multipart
POST endpoint. Zod-validates recipients (≤20), fields (≤200), PDF
size (≤50MB), all 11 Documenso field types. Permission-gated by
documents.send_for_signing + interests.edit (matches the
external-eoi precedent — the auto-advance side-effect is
interest-mutating).
Files modified: none — keeps the existing tab placeholders as the
entry point; Phase 4 builds the drag-drop UI on top.
Validation contract pinned by 8 unit tests covering: empty recipient
list, empty field list, empty/oversized PDF, non-PDF magic bytes,
out-of-range + negative recipientIndex, duplicate signingOrder.
The heavy paths (storage put, Documenso HTTP, signer update) are
exercised by the existing realapi Playwright project — no new
realapi specs added because the contract-upload UI doesn't exist yet
to drive them.
Verified against Documenso API spec (v1 OpenAPI + v2 docs via
Context7): recipients[].token is on the Recipient model in both
versions; webhook payloads echo the same shape so the Phase 2 token-
match handler works against custom-uploaded docs without changes.
Tests: 1326 → 1334 ✅; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:52:21 +02:00
|
|
|
}).catch((err) => {
|
|
|
|
|
logger.error(
|
|
|
|
|
{ err, documentId: docRow.id, signerId: refreshed.id },
|
|
|
|
|
'Auto-send invitation failed (manual retry via Send button still available)',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
await db
|
|
|
|
|
.update(documentSigners)
|
|
|
|
|
.set({ invitedAt: new Date() })
|
|
|
|
|
.where(eq(documentSigners.id, refreshed.id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
documentId: docRow.id,
|
|
|
|
|
documensoDocumentId: documensoDoc.id,
|
|
|
|
|
signingUrls,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* 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
|
|
|
|
|
* service stays role-blind).
|
|
|
|
|
*/
|
|
|
|
|
function documensoRoleToLocal(role: CustomRecipientRole): SignerRole {
|
|
|
|
|
switch (role) {
|
|
|
|
|
case 'APPROVER':
|
|
|
|
|
return 'approver';
|
|
|
|
|
case 'CC':
|
|
|
|
|
return 'other';
|
|
|
|
|
case 'SIGNER':
|
|
|
|
|
default:
|
|
|
|
|
return 'other';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-export the client type so callers don't have to import from two
|
|
|
|
|
// places when building the field array.
|
|
|
|
|
export type { DocumensoFieldPlacement } from '@/lib/services/documenso-client';
|
|
|
|
|
|
|
|
|
|
// Re-export to silence unused-import lint when the union is consumed
|
|
|
|
|
// only indirectly via downstream type inference.
|
|
|
|
|
export type { CustomDocumentType as _CustomDocumentType };
|
|
|
|
|
|
|
|
|
|
// Keep the clients import referenced — used by future enhancements
|
|
|
|
|
// that resolve the client name for default recipient prefill.
|
|
|
|
|
void clients;
|