Files
pn-new-crm/src/lib/services/custom-document-upload.service.ts

742 lines
29 KiB
TypeScript
Raw Normal View History

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.
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
*
* 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 -
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
* 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
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
* 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,
voidDocument as documensoVoid,
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
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 / 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
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
* 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 {
/** Optional interest the doc is filed under. Required for eoi /
* contract / reservation_agreement (their pipeline-stage side
* effects need it); MUST be null for 'generic' (cross-cutting
* envelopes that aren't tied to a sales deal). */
interestId: string | null;
/** Optional entity context drives the auto-filed folder + the
* file-row FK. Used by the 'generic' path when there's no interest
* to derive the client from. Ignored when `interestId` is set
* (the service resolves the client off the interest itself). */
entity?: { type: 'client' | 'company' | 'yacht'; id: string } | null;
/** Optional explicit folder placement. When set, overrides the
* entity-derived folder (e.g. rep dropped the upload into a
* specific subfolder from the Documents Hub). */
folderId?: 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
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
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
* 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
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:17:39 +02:00
* 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
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
export async function uploadDocumentForSigning(
args: UploadDocumentForSigningArgs,
): Promise<UploadDocumentForSigningResult> {
const {
interestId,
entity,
folderId: explicitFolderId,
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
portId,
portSlug,
documentType,
title,
pdfBuffer,
filename,
recipients,
fields,
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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;
// Generic envelopes (no pipeline-stage advance / no interest) MUST
// come in with interestId=null; non-generic types MUST carry an
// interest. Reject the mismatch here so the rest of the function can
// assume the right invariant.
if (documentType !== 'generic' && !interestId) {
throw new ValidationError(
`${documentType} document requires an interestId — only 'generic' documents can be uploaded without one`,
);
}
if (documentType === 'generic' && interestId) {
throw new ValidationError(
'Generic documents cannot carry an interestId — use a type-specific document type instead',
);
}
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
// ─── 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.
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
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)`,
);
}
}
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
// Recipient-level validation — emails and names. Documenso's API
// can't recover from a missing/invalid email (it'll silently drop the
// recipient or accept the envelope without distribution), so hard
// block at the service boundary. The UI mirrors this validation
// pre-Submit so the rep sees the issue before the round-trip.
const recipientEmailRegex = /^\S+@\S+\.\S+$/;
for (const r of recipients) {
const email = (r.email ?? '').trim();
if (!email) {
throw new ValidationError(`Recipient #${r.signingOrder} is missing an email address`);
}
if (!recipientEmailRegex.test(email)) {
throw new ValidationError(
`Recipient #${r.signingOrder} has an invalid email address: ${email}`,
);
}
const name = (r.name ?? '').trim();
if (!name) {
throw new ValidationError(`Recipient #${r.signingOrder} is missing a name`);
}
}
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
// 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 ────────────────────────────────────────────────
// Non-generic types resolve their interest (and derive the client
// from there). Generic types skip the interest lookup; entity FK
// routing comes from the caller-supplied `entity` arg.
const interest = interestId
? await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
})
: null;
if (interestId && !interest) throw new NotFoundError('Interest');
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
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();
// 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';
// Storage path groups by interestId when we have one; for generic
// uploads the entity id (or a synthetic 'unfiled' bucket) keeps the
// namespace tidy.
const storageGroupId = interestId ?? entity?.id ?? 'unfiled';
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
const sourceStoragePath = buildStoragePath(
portSlug,
storageCategory,
storageGroupId,
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
sourceFileId,
'pdf',
);
const storage = await getStorageBackend();
await storage.put(sourceStoragePath, pdfBuffer, {
contentType: PDF_MIME,
sizeBytes: pdfBuffer.length,
});
// Folder placement priority:
// 1. Caller-supplied `folderId` (rep dropped the upload into a
// specific Documents Hub folder).
// 2. Interest's primary client folder (legacy path for
// EOI/contract/reservation tabs).
// 3. Caller-supplied entity (generic path: client/company/yacht
// doc tab originated the upload).
// 4. Root (fallback).
let entityFolderId: string | null = explicitFolderId ?? null;
if (entityFolderId === null && interest?.clientId) {
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
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',
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
);
}
}
if (entityFolderId === null && entity) {
try {
const folder = await ensureEntityFolder(portId, entity.type, entity.id, 'system');
entityFolderId = folder.id;
} catch (err) {
logger.warn(
{ err, entity },
'ensureEntityFolder failed for generic upload entity - filing at root',
);
}
}
// Derive the entity-FK fields on the `files` row from whichever
// source we have. Interest-derived takes priority; otherwise the
// generic `entity` arg maps to its corresponding column.
const fileEntityFKs: {
clientId: string | null;
companyId: string | null;
yachtId: string | null;
} = {
clientId: interest?.clientId ?? (entity?.type === 'client' ? entity.id : null),
companyId: entity?.type === 'company' ? entity.id : null,
yachtId: entity?.type === 'yacht' ? entity.id : 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
const [sourceFileRecord] = await db
.insert(files)
.values({
portId,
clientId: fileEntityFKs.clientId,
companyId: fileEntityFKs.companyId,
yachtId: fileEntityFKs.yachtId,
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
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
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
// 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: fileEntityFKs.clientId,
companyId: fileEntityFKs.companyId,
yachtId: fileEntityFKs.yachtId,
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
fileId: sourceFileRecord.id,
documentType,
title,
status: 'draft',
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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,
}));
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
// ─── State machine: create → send → place → promote ────────────
// Every step that can fail registers its rollback contribution on
// `state`. The single catch at the end runs `rollbackTo(state)`,
// which composes the recovery: status='cancelled' on the local row
// always, void the Documenso envelope only when we created one.
// Idempotent — calling it twice is safe (status flip is a no-op the
// second time, voidDocument treats 404 as success).
type UploadStep = 'create' | 'send' | 'place' | 'promote';
interface UploadState {
step: UploadStep | null;
documensoDocId: string | null;
}
const state: UploadState = { step: null, documensoDocId: null };
// Cache the row id in a local const so the closure below doesn't fight
// TypeScript's `Row | undefined` narrowing through the closure
// boundary. The if (!docRow) guard above already established it's
// defined here.
const docRowId = docRow.id;
async function rollbackTo(reason: unknown): Promise<void> {
logger.warn(
{
documentId: docRowId,
documensoEnvelopeId: state.documensoDocId,
failedStep: state.step,
err: reason instanceof Error ? { message: reason.message, name: reason.name } : reason,
},
'Rolling back custom document upload',
);
await db
.update(documents)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(eq(documents.id, docRowId));
if (state.documensoDocId) {
await documensoVoidSafe(state.documensoDocId, portId);
}
// Failure audit-log entry — captures which step failed, the
// Documenso envelope id (if any), and the error class/message so
// post-mortem investigation doesn't have to dig through structured
// logs. Success-path audit is at the end of the function; this is
// the failure-path counterpart.
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'document',
entityId: docRowId,
newValue: {
status: 'cancelled',
failedStep: state.step ?? 'unknown',
documensoEnvelopeId: state.documensoDocId,
errorClass: reason instanceof Error ? reason.name : null,
errorMessage: reason instanceof Error ? reason.message : String(reason),
},
metadata: {
type: 'upload_for_signing_rollback',
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
let documensoDoc: Awaited<ReturnType<typeof documensoCreate>>;
let sentDoc: Awaited<ReturnType<typeof documensoSend>>;
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
try {
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
// Step 1 — create envelope in Documenso.
state.step = 'create';
documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
});
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
state.documensoDocId = documensoDoc.id;
// Persist documensoId IMMEDIATELY so any subsequent failure leaves
// the CRM row pointing at the envelope. Without this, a throw
// between documensoCreate and the late `status: 'sent'` update
// would orphan the envelope in Documenso (CRM has the local row but
// no link to find the upstream envelope to void). UAT 2026-05-26
// hit exactly this: CRM document row stuck in 'draft' status,
// documensoId=NULL, Documenso hosting a live envelope nothing
// referenced. The late `status: 'sent'` UPDATE still runs and the
// idempotent re-write of documensoId is fine.
await db
.update(documents)
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
.set({ documensoId: documensoDoc.id, updatedAt: new Date() })
.where(eq(documents.id, docRow.id));
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
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
// Step 2 — distribute (Documenso v2) / send (v1). Resolves the
// recipient ids that we need for field placement next.
state.step = 'send';
sentDoc = await documensoSend(documensoDoc.id, portId);
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
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
// Step 3 — recipient identity reconciliation + field placement.
// Documenso de-dupes by email at the envelope level, so multiple
// CRM-side Recipient rows that share an email all map to the same
// Documenso recipientId — that's fine for field placement (both
// rows target the same Documenso recipient).
state.step = 'place';
const emailToRecipientId = new Map<string, string>();
for (const dr of sentDoc.recipients) {
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
}
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
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
// Reconciliation guard: every distinct CRM email we sent must
// appear in sentDoc.recipients. If Documenso silently dropped one
// (invalid email format that passed our regex, etc.), we want a
// loud failure that triggers the rollback path — NOT a half-placed
// doc that ships to signers with missing fields.
const sentEmails = new Set(Array.from(emailToRecipientId.keys()).map((k) => k.toLowerCase()));
const missingFromDocumenso = recipients
.map((r) => r.email.trim().toLowerCase())
.filter((email, idx, arr) => arr.indexOf(email) === idx) // dedupe
.filter((email) => !sentEmails.has(email));
if (missingFromDocumenso.length > 0) {
logger.error(
{
documentId: docRow.id,
documensoEnvelopeId: documensoDoc.id,
missingFromDocumenso,
documensoReturned: Array.from(emailToRecipientId.keys()),
},
'Recipient reconciliation: Documenso response missing emails the CRM sent',
);
throw new ConflictError(
`Documenso accepted the envelope but didn't echo recipient(s) for: ${missingFromDocumenso.join(', ')}. ` +
`Cannot place fields — recipients aren't reachable.`,
);
}
// Build placements + place fields inside the same try block so the
// synchronous map() throw (when a recipient can't be matched)
// triggers rollback alongside any async placeFields() throw.
feat(documenso): rejection reason + poll fallback + rollback hardening + recipient UX Documenso reliability + signer-UX bundle from the 2026-05-26 live UAT. Each piece detailed in docs/superpowers/audits/active-uat.md with full file:line + root cause + alternatives. Webhook + poll convergence - DocumensoRecipient (webhook payload type) gains rejectionReason + declineReason. The DOCUMENT_REJECTED / DOCUMENT_DECLINED handler coalesces them at the boundary so downstream code sees one stable field. Empty/whitespace normalised to null. - DocumensoDocument.recipients[] (normalized client output) gains rejectionReason. normalizeDocument coalesces v2 + v1 field names the same way so poller consumers see identical shape. - handleDocumentRejected signature gains rejectionReason. Stored on document_events.eventData, persisted in audit_logs metadata, quoted inline in the in-CRM rep notification (truncated 120 chars; full reason still on the audit row). New 'transfer' AuditAction added alongside. - signature-poll job now handles REJECTED / DECLINED. Previously only SIGNED / COMPLETED / EXPIRED were reconciled, so a missed rejection webhook (stale tunnel URL is the typical dev cause) left documents stuck in 'sent' forever. The 5-min poll cycle now closes that gap — webhook becomes an optimisation, not a correctness requirement. placeFields rollback gap - custom-document-upload.service moved the synchronous field-placement map() INSIDE the same try/catch that wraps placeFields(). Previously the map's throw bubbled past the catch-and-rollback block, leaving Documenso with a live envelope + recipients but no fields, and the CRM document row stuck in 'sent' with no signing UI for the signers. Logger captures looked-up email + map keys on miss for diagnosis. - Comment documents Documenso's by-email dedupe semantic so future readers don't reintroduce the per-recipient-row map assumption. UploadForSigningDialog recipient UX - New RECIPIENT_ROLE_META + RecipientRoleBadge helpers. Placement-step sidebar list rebuilt as a two-line layout (name + role badge / email on its own line) so duplicate-named recipients are visually distinguishable. FieldSidePanel dropdown SelectItem mirrors the same stacked shape. - "Recipient" label renamed to "Assign this field to" with an explainer paragraph below. SigningProgress copy-link parity - Copy-link button now always renders for pending signers (disabled + explainer tooltip when signingUrl not yet issued). Reps can copy even when the URL hasn't been distributed via email yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:06:12 +02:00
const placements: DocumensoFieldPlacement[] = fields.map((f) => {
const recipient = recipients[f.recipientIndex]!;
const recipientId = emailToRecipientId.get(recipient.email.toLowerCase());
if (!recipientId) {
logger.error(
{
documentId: docRow.id,
documensoEnvelopeId: documensoDoc.id,
lookedUpEmail: recipient.email,
availableEmails: Array.from(emailToRecipientId.keys()),
},
'Documenso recipient lookup miss during field placement',
);
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);
} catch (err) {
feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests Phase 1 of the comprehensive Documenso upload audit per the 2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md. P1.1 — persist documensoId immediately after create Was set only at the late `status: 'sent'` commit. Any throw between documensoCreate and the late update left an orphaned Documenso envelope the CRM had no link to. Now the UPDATE runs right after documensoCreate succeeds; rollback paths can find and void the envelope. P1.2 — pre-flight validation hard-blocks Submit UploadForSigningDialog computes a submissionErrors memo over recipients + fields. Submit button disabled when errors > 0. Inline amber summary lists every issue (missing email, invalid email, missing name, field assigned to non-existent recipient, no fields placed). Service layer mirrors the same email + name checks so direct API hits reject early. No override path per locked decision. P1.3 — cancel/delete affordance audit + sweep Document-list per-row Delete + Send for Signing actions now: - Wrapped in PermissionGate (documents.delete + send_for_signing). - Surface toast on success + toastError on failure (were silently swallowing errors). - Use a broader predicate-based query invalidation so every doc list across the app refreshes, not just the local key. EOI tab Regenerate + Cancel EOI buttons + reservation/contract tab Cancel buttons wrapped in PermissionGate (documents.edit, the cancel route's auth check). P1.4 — Documenso webhook URL auto-PATCH (env-gated) scripts/update-documenso-webhook.ts written. Reads DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise no-op). Lists every webhook on the Documenso instance via v2 (with v1 fallback), identifies webhooks pointing at trycloudflare.com hosts OR /api/webhooks/documenso paths, PATCHes them to the new tunnel URL. scripts/tunnel-url.sh chains the script after the URL print so a re-tunnel auto-rotates the webhook (when flag set). P1.5 — state-machine refactor with rollbackTo() helper custom-document-upload.service.ts: - Single try around create → send → place steps. - state.step tracks which step is current; state.documensoDocId records the envelope id once we have it. - rollbackTo(reason) composes the recovery: status='cancelled' on the CRM row, documensoVoidSafe on the envelope when applicable. Idempotent — calling twice is safe. - Removes three independent try/catches. P1.6 — recipient ↔ Documenso identity reconciliation After documensoSend, validates every distinct email we sent appears in sentDoc.recipients. If Documenso silently dropped one, a ConflictError fires before field placement so the rollback path triggers. Explicit message names the missing emails for the rep. P1.7 — vitest extension + per-failure audit-log entries - 5 new vitest cases (blank email, whitespace email, malformed email, blank name, duplicate-emails-OK semantic). - rollbackTo writes a structured audit_log entry with failedStep, documensoEnvelopeId, errorClass, errorMessage. Post-mortem investigation has structured data instead of just logger lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
await rollbackTo(err);
throw err;
}
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
// 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: 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' && interestId) {
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));
}
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
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,
feat(documenso-phase-6): activity badges + per-document invitation message Two of the six Phase 6 polish items shipped in one commit because they share the data + plumbing path (per-doc message uses the signing- progress UI's existing layout). 1) Signing-progress activity badges - Surfaces `invitedAt`, `openedAt`, `lastReminderSentAt` (all populated by Phase 1+2 webhook handlers) per signer in the existing progress widget. Each badge renders as "Invited 2 hours ago / Opened yesterday / Reminded 3 days ago" via Intl.RelativeTimeFormat. - Resend button: was silent on success/failure; now uses useMutation + toast so the rep sees whether the reminder fired or fell into a cadence cooldown. Honours the existing sendReminderIfAllowed return shape (`{sent, reason}`). - Title-tooltips on each badge show the exact ISO timestamp. 2) Per-document custom invitation message - New `documents.invitation_message` column (migration 0060; applied via psql per the dev-flow note in CLAUDE.md). - Textarea in UploadForSigningDialog step 2 (recipient configurator), 1000-char cap, placeholder text shows the expected tone. - custom-document-upload.service accepts `invitationMessage`, trims + stores on the documents row. - sendCascadingInviteForNextSigner now reads doc.invitationMessage and passes as customMessage so every cascaded recipient (developer / approver / witness) sees the same note — not just the first signer. - send-invitation route (manual resend path) reads the same column → customMessage so manual reminders match. - The email template's existing customMessage rendering does the XSS escape; no other plumbing needed. Phase 6 items still deferred (each ~2-3h, mostly independent): - Auto-send delay (`eoi_send_delay_minutes` setting + scheduled BullMQ job — needs a scheduler hook). - Document expiration (`documents.expires_at` + Documenso `expiresAt` passthrough — needs Documenso v2 endpoint shape verification). - Failed-webhook recovery admin UI (the BullMQ DLQ exists; needs an admin page with Replay button). Tests: 1340 → 1350 ✅; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
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
* 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
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
* 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
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
// 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',
);
}
}