import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documents, documentSigners, documentEvents, documentWatchers, 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 { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog } from '@/lib/audit'; import { diffEntity } from '@/lib/entity-diff'; import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { minioClient, buildStoragePath } from '@/lib/minio'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { evaluateRule } from '@/lib/services/berth-rules-engine'; import { createDocument as documensoCreate, sendDocument as documensoSend, downloadSignedPdf, voidDocument as documensoVoid, } from '@/lib/services/documenso-client'; import type { CreateDocumentInput, UpdateDocumentInput, ListDocumentsInput, } from '@/lib/validators/documents'; // ─── Types ──────────────────────────────────────────────────────────────────── interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── List ───────────────────────────────────────────────────────────────────── export async function listDocuments(portId: string, query: ListDocumentsInput) { const { page, limit, sort, order, search, interestId, clientId, documentType, status } = query; const filters = []; if (interestId) filters.push(eq(documents.interestId, interestId)); if (clientId) filters.push(eq(documents.clientId, clientId)); if (documentType) filters.push(eq(documents.documentType, documentType)); if (status) filters.push(eq(documents.status, status)); const sortColumn = sort === 'title' ? documents.title : sort === 'status' ? documents.status : sort === 'documentType' ? documents.documentType : documents.createdAt; return buildListQuery({ table: documents, portIdColumn: documents.portId, portId, idColumn: documents.id, updatedAtColumn: documents.updatedAt, searchColumns: [documents.title], searchTerm: search, filters, sort: sort ? { column: sortColumn, direction: order } : undefined, page, pageSize: limit, }); } // ─── Get by ID ──────────────────────────────────────────────────────────────── export async function getDocumentById(id: string, portId: string) { const doc = await db.query.documents.findFirst({ where: and(eq(documents.id, id), eq(documents.portId, portId)), with: { signers: true }, }); if (!doc) throw new NotFoundError('Document'); return doc; } // ─── Create ─────────────────────────────────────────────────────────────────── export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) { const [doc] = await db .insert(documents) .values({ portId, interestId: data.interestId ?? null, clientId: data.clientId ?? null, documentType: data.documentType, title: data.title, notes: data.notes ?? null, status: 'draft', createdBy: meta.userId, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'document', entityId: doc!.id, newValue: { documentType: doc!.documentType, title: doc!.title }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:created', { documentId: doc!.id }); return doc!; } // ─── Update ─────────────────────────────────────────────────────────────────── export async function updateDocument( id: string, portId: string, data: UpdateDocumentInput, meta: AuditMeta, ) { const existing = await getDocumentById(id, portId); const updates: Partial = {}; if (data.title !== undefined) updates.title = data.title; if (data.notes !== undefined) updates.notes = data.notes; if (data.status !== undefined) updates.status = data.status; updates.updatedAt = new Date(); const [updated] = await db .update(documents) .set(updates) .where(and(eq(documents.id, id), eq(documents.portId, portId))) .returning(); diffEntity(existing, updated!); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document', entityId: id, oldValue: existing as unknown as Record, newValue: updated as unknown as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:updated', { documentId: id }); return updated!; } // ─── Delete ─────────────────────────────────────────────────────────────────── export async function deleteDocument(id: string, portId: string, meta: AuditMeta) { const existing = await getDocumentById(id, portId); if (['sent', 'partially_signed'].includes(existing.status)) { throw new ConflictError('Cannot delete a document that is currently in signing process'); } await db.delete(documents).where(and(eq(documents.id, id), eq(documents.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'document', entityId: id, oldValue: { title: existing.title, status: existing.status }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:deleted', { documentId: id }); } // ─── Send for Signing (BR-021) ──────────────────────────────────────────────── export async function sendForSigning(documentId: string, portId: string, meta: AuditMeta) { const doc = await getDocumentById(documentId, portId); if (!doc.fileId) throw new ValidationError('Document has no associated file'); if (doc.status !== 'draft') throw new ConflictError('Document is not in draft status'); // Fetch interest + client to build signers const interest = doc.interestId ? await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId) }) : null; const client = doc.clientId ? await db.query.clients.findFirst({ where: eq(clients.id, doc.clientId), with: { contacts: true }, }) : null; if (!client) throw new ValidationError('Document has no associated client'); const emailContact = ( client.contacts as Array<{ channel: string; value: string }> | undefined )?.find((c) => c.channel === 'email'); if (!emailContact?.value) throw new ValidationError('Client has no email contact'); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (!port) throw new NotFoundError('Port'); // BR-021: Create 3 signers — client (1), developer (2), sales/approver (3) const signerRecords = await db .insert(documentSigners) .values([ { documentId, signerName: client.fullName, signerEmail: emailContact.value, signerRole: 'client', signingOrder: 1, status: 'pending', }, { documentId, signerName: port.name, signerEmail: `developer@${port.slug}.com`, signerRole: 'developer', signingOrder: 2, status: 'pending', }, { documentId, signerName: `${port.name} Sales`, signerEmail: `sales@${port.slug}.com`, signerRole: 'approver', signingOrder: 3, status: 'pending', }, ]) .returning(); // Get file from MinIO and base64 encode const fileRecord = await db.query.files.findFirst({ where: eq(files.id, doc.fileId) }); if (!fileRecord) throw new NotFoundError('File'); const fileStream = await minioClient.getObject(env.MINIO_BUCKET, fileRecord.storagePath); const chunks: Buffer[] = []; for await (const chunk of fileStream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const pdfBuffer = Buffer.concat(chunks); const pdfBase64 = pdfBuffer.toString('base64'); // Create document in Documenso + send const documensoDoc = await documensoCreate(doc.title, pdfBase64, [ { name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 }, { name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 }, { name: `${port.name} Sales`, email: `sales@${port.slug}.com`, role: 'SIGNER', signingOrder: 3, }, ]); await documensoSend(documensoDoc.id); // Update signer records with signing URLs from Documenso response for (const docSigner of documensoDoc.recipients) { const localSigner = signerRecords.find((s) => s.signerEmail === docSigner.email); if (localSigner) { await db .update(documentSigners) .set({ signingUrl: docSigner.signingUrl ?? null, embeddedUrl: docSigner.embeddedUrl ?? null, }) .where(eq(documentSigners.id, localSigner.id)); } } // Update document status await db .update(documents) .set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() }) .where(eq(documents.id, documentId)); // Update interest if linked if (interest) { await db .update(interests) .set({ documensoId: documensoDoc.id, dateEoiSent: new Date(), eoiStatus: 'waiting_for_signatures', updatedAt: new Date(), }) .where(eq(interests.id, interest.id)); // Trigger berth rules void evaluateRule('eoi_sent', interest.id, portId, meta); } // Create document event await db.insert(documentEvents).values({ documentId, eventType: 'sent', eventData: { documensoId: documensoDoc.id }, }); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document', entityId: documentId, newValue: { status: 'sent', documensoId: documensoDoc.id }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:sent', { documentId, type: doc.documentType, signerCount: 3, documensoId: documensoDoc.id, }); return await getDocumentById(documentId, portId); } // ─── Upload Signed Manually (BR-013) ───────────────────────────────────────── export async function uploadSignedManually( documentId: string, portId: string, fileData: { buffer: Buffer; originalName: string; mimeType: string; size: number }, meta: AuditMeta, ) { const doc = await getDocumentById(documentId, portId); const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) }); if (!port) throw new NotFoundError('Port'); // Store the signed file const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf'); await minioClient.putObject(env.MINIO_BUCKET, storagePath, fileData.buffer, fileData.size, { 'Content-Type': fileData.mimeType, }); const [fileRecord] = await db .insert(files) .values({ portId, clientId: doc.clientId ?? null, filename: fileData.originalName, originalName: fileData.originalName, mimeType: fileData.mimeType, sizeBytes: String(fileData.size), storagePath, storageBucket: env.MINIO_BUCKET, category: 'eoi', uploadedBy: meta.userId, }) .returning(); // Update document await db .update(documents) .set({ signedFileId: fileRecord!.id, status: 'completed', isManualUpload: true, updatedAt: new Date(), }) .where(eq(documents.id, documentId)); // Update interest if linked and type is eoi if (doc.interestId && doc.documentType === 'eoi') { const interest = await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId), }); await db .update(interests) .set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() }) .where(eq(interests.id, doc.interestId)); if (interest) { void evaluateRule('eoi_signed', doc.interestId, portId, meta); } } await db.insert(documentEvents).values({ documentId, eventType: 'completed', eventData: { isManualUpload: true, fileId: fileRecord!.id }, }); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document', entityId: documentId, newValue: { status: 'completed', isManualUpload: true }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:completed', { documentId }); // Notify creator about manual completion void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId, userId: meta.userId, type: 'document_signed', title: 'Document marked as signed', description: `"${doc.title}" has been manually uploaded as signed`, link: `/documents/${documentId}`, entityType: 'document', entityId: documentId, dedupeKey: `document:${documentId}:completed`, }), ); return await getDocumentById(documentId, portId); } // ─── List Signers ───────────────────────────────────────────────────────────── export async function listDocumentSigners(documentId: string, portId: string) { await getDocumentById(documentId, portId); // verify access return db.query.documentSigners.findMany({ where: eq(documentSigners.documentId, documentId), orderBy: (ds, { asc }) => [asc(ds.signingOrder)], }); } // ─── List Events ────────────────────────────────────────────────────────────── export async function listDocumentEvents(documentId: string, portId: string) { await getDocumentById(documentId, portId); // verify access return db.query.documentEvents.findMany({ where: eq(documentEvents.documentId, documentId), orderBy: (de, { desc }) => [desc(de.createdAt)], }); } // ─── Webhook Handlers ───────────────────────────────────────────────────────── export async function handleRecipientSigned(eventData: { documentId: string; recipientEmail: string; signatureHash?: string; }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } // Update signer status const [signer] = await db .update(documentSigners) .set({ status: 'signed', signedAt: new Date() }) .where( and( eq(documentSigners.documentId, doc.id), eq(documentSigners.signerEmail, eventData.recipientEmail), ), ) .returning(); // Update document to partially_signed if eoi type if (doc.documentType === 'eoi' && doc.status === 'sent') { await db .update(documents) .set({ status: 'partially_signed', updatedAt: new Date() }) .where(eq(documents.id, doc.id)); } await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'signed', signerId: signer?.id ?? null, signatureHash: eventData.signatureHash ?? null, eventData: { recipientEmail: eventData.recipientEmail }, }); emitToRoom(`port:${doc.portId}`, 'document:signer:signed', { documentId: doc.id, signerEmail: eventData.recipientEmail, }); } export async function handleDocumentCompleted(eventData: { documentId: string }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } // BR-022: Download signed PDF and store in MinIO const port = await db.query.ports.findFirst({ where: eq(ports.id, doc.portId) }); if (!port) { logger.error({ portId: doc.portId }, 'Port not found during document completion'); return; } try { const signedPdfBuffer = await downloadSignedPdf(eventData.documentId); const fileId = crypto.randomUUID(); const storagePath = buildStoragePath(port.slug, 'eoi-signed', doc.id, fileId, 'pdf'); await minioClient.putObject( env.MINIO_BUCKET, storagePath, signedPdfBuffer, signedPdfBuffer.length, { 'Content-Type': 'application/pdf' }, ); const [fileRecord] = await db .insert(files) .values({ portId: doc.portId, clientId: doc.clientId ?? null, filename: `signed-${doc.id}.pdf`, originalName: `signed-${doc.id}.pdf`, mimeType: 'application/pdf', sizeBytes: String(signedPdfBuffer.length), storagePath, storageBucket: env.MINIO_BUCKET, category: 'eoi', uploadedBy: 'system', }) .returning(); await db .update(documents) .set({ status: 'completed', signedFileId: fileRecord!.id, updatedAt: new Date() }) .where(eq(documents.id, doc.id)); } catch (err) { logger.error({ err, documentId: doc.id }, 'Failed to download/store signed PDF'); await db .update(documents) .set({ status: 'completed', updatedAt: new Date() }) .where(eq(documents.id, doc.id)); } // Update interest if eoi type if (doc.interestId && doc.documentType === 'eoi') { const interest = await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId), }); await db .update(interests) .set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() }) .where(eq(interests.id, doc.interestId)); if (interest) { void evaluateRule('eoi_signed', doc.interestId, doc.portId, { userId: 'system', portId: doc.portId, ipAddress: '0.0.0.0', userAgent: 'webhook', }); } } await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'completed', eventData: { documensoId: eventData.documentId }, }); emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id }); // Notify the document creator about completion if (doc.createdBy && doc.createdBy !== 'system') { void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId: doc.portId, userId: doc.createdBy!, type: 'document_signed', title: 'Document fully signed', description: `"${doc.title}" has been signed by all parties`, link: `/documents/${doc.id}`, entityType: 'document', entityId: doc.id, dedupeKey: `document:${doc.id}:completed`, }), ); } } export async function handleDocumentExpired(eventData: { documentId: string }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } await db .update(documents) .set({ status: 'expired', updatedAt: new Date() }) .where(eq(documents.id, doc.id)); if (doc.interestId && doc.documentType === 'eoi') { await db .update(interests) .set({ eoiStatus: 'expired', updatedAt: new Date() }) .where(eq(interests.id, doc.interestId)); } await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'expired', eventData: { documensoId: eventData.documentId }, }); emitToRoom(`port:${doc.portId}`, 'document:expired', { documentId: doc.id }); } export async function handleDocumentOpened(eventData: { documentId: string; recipientEmail: string; signatureHash?: string; }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } const [signer] = await db .select() .from(documentSigners) .where( and( eq(documentSigners.documentId, doc.id), eq(documentSigners.signerEmail, eventData.recipientEmail), ), ); await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'viewed', signerId: signer?.id ?? null, signatureHash: eventData.signatureHash ?? null, eventData: { recipientEmail: eventData.recipientEmail }, }); emitToRoom(`port:${doc.portId}`, 'document:signer:opened', { documentId: doc.id, signerEmail: eventData.recipientEmail, }); } export async function handleDocumentRejected(eventData: { documentId: string; recipientEmail?: string; signatureHash?: string; }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } let signerId: string | null = null; if (eventData.recipientEmail) { const [signer] = await db .update(documentSigners) .set({ status: 'declined' }) .where( and( eq(documentSigners.documentId, doc.id), eq(documentSigners.signerEmail, eventData.recipientEmail), ), ) .returning(); signerId = signer?.id ?? null; } await db .update(documents) .set({ status: 'rejected', updatedAt: new Date() }) .where(eq(documents.id, doc.id)); if (doc.interestId && doc.documentType === 'eoi') { await db .update(interests) .set({ eoiStatus: 'rejected', updatedAt: new Date() }) .where(eq(interests.id, doc.interestId)); } await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'rejected', signerId, signatureHash: eventData.signatureHash ?? null, eventData: { recipientEmail: eventData.recipientEmail ?? null }, }); emitToRoom(`port:${doc.portId}`, 'document:rejected', { documentId: doc.id, signerEmail: eventData.recipientEmail ?? null, }); } export async function handleDocumentCancelled(eventData: { documentId: string; signatureHash?: string; }) { const doc = await db.query.documents.findFirst({ where: eq(documents.documensoId, eventData.documentId), }); if (!doc) { logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook'); return; } await db .update(documents) .set({ status: 'cancelled', updatedAt: new Date() }) .where(eq(documents.id, doc.id)); if (doc.interestId && doc.documentType === 'eoi') { await db .update(interests) .set({ eoiStatus: 'cancelled', updatedAt: new Date() }) .where(eq(interests.id, doc.interestId)); } await db.insert(documentEvents).values({ documentId: doc.id, eventType: 'cancelled', signatureHash: eventData.signatureHash ?? null, eventData: { documensoId: eventData.documentId }, }); emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id }); } // ─── Phase A: hub + wizard surface (PR1 skeletons; bodies land in PRs 4-6) ──── export interface DocumentDetailWatcher { userId: string; addedBy: string; addedAt: Date; } export interface DocumentDetail { document: typeof documents.$inferSelect; signers: (typeof documentSigners.$inferSelect)[]; events: (typeof documentEvents.$inferSelect)[]; watchers: DocumentDetailWatcher[]; } /** * Single-roundtrip aggregator for the document detail page (PR5). * Returns the document plus all signers, events (newest first), and watchers. * Throws NotFoundError if the document is not in `portId`. */ export async function getDocumentDetail(id: string, portId: string): Promise { const document = await getDocumentById(id, portId); const [signers, events, watchers] = await Promise.all([ db.query.documentSigners.findMany({ where: eq(documentSigners.documentId, id), orderBy: (ds, { asc }) => [asc(ds.signingOrder)], }), db.query.documentEvents.findMany({ where: eq(documentEvents.documentId, id), orderBy: (de, { desc }) => [desc(de.createdAt)], }), db .select({ userId: documentWatchers.userId, addedBy: documentWatchers.addedBy, addedAt: documentWatchers.addedAt, }) .from(documentWatchers) .where(eq(documentWatchers.documentId, id)), ]); return { document, signers, events, watchers }; } /** * User-initiated cancel of an in-flight document. Voids the doc in Documenso * (when present), updates DB status, logs an event, emits socket. Webhook * receiver also handles documenso-initiated cancellations via * `handleDocumentCancelled`. * * The actual Documenso void call lands in PR2 (`documenso-client.voidDocument`); * this skeleton updates DB state only. */ export async function cancelDocument( documentId: string, portId: string, meta: AuditMeta, ): Promise { const existing = await getDocumentById(documentId, portId); if (['completed', 'cancelled', 'rejected'].includes(existing.status)) { throw new ConflictError(`Document is already ${existing.status}`); } // CRM is the system of record for cancellation status. A transient // Documenso failure shouldn't block the user from marking the doc cancelled // here — voidDocument already treats 404 as success, and the periodic // webhook receiver will reconcile if the remote void eventually lands. if (existing.documensoId) { try { await documensoVoid(existing.documensoId, portId); } catch (err) { logger.warn( { err, documentId, documensoId: existing.documensoId }, 'Documenso void failed; cancelling locally anyway', ); } } const [updated] = await db .update(documents) .set({ status: 'cancelled', updatedAt: new Date() }) .where(and(eq(documents.id, documentId), eq(documents.portId, portId))) .returning(); await db.insert(documentEvents).values({ documentId, eventType: 'cancelled', eventData: { initiatedBy: meta.userId }, }); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'document', entityId: documentId, oldValue: { status: existing.status }, newValue: { status: 'cancelled' }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'document:cancelled', { documentId }); return updated!; } /** * Returns prefilled email composer payload for the "Email signed PDF to all * signatories" action on the document detail page. * * Available for `status='completed' && signedFileId !== null`. * * Body content (from per-port `signed_doc_completion` template), full * sender-resolution, and watcher-cc helpers land alongside PR8 (email * composer with attachments). For PR1 this returns the minimal correct * recipients + auto-attachment shape so detail-page integration tests can * assert against it. */ export interface ComposeSignedDocEmailResult { to: string[]; cc: string[]; subject: string; body: string; attachments: Array<{ fileId: string; filename?: string }>; defaultSenderType: 'system' | 'user'; } export async function composeSignedDocEmail( documentId: string, portId: string, ): Promise { const doc = await getDocumentById(documentId, portId); if (doc.status !== 'completed') { throw new ConflictError('Document is not completed'); } if (!doc.signedFileId) { throw new ValidationError('Document has no signed PDF'); } const signers = await db .select({ email: documentSigners.signerEmail }) .from(documentSigners) .where(eq(documentSigners.documentId, documentId)); const dedupedRecipients = Array.from(new Set(signers.map((s) => s.email))); return { to: dedupedRecipients, cc: [], subject: `Signed ${doc.documentType.replace(/_/g, ' ')} — ${doc.title}`, body: '', attachments: [{ fileId: doc.signedFileId }], defaultSenderType: 'system', }; } /** * Skeleton for the create-document wizard entry point (PR6). * * Dispatches across the three pathways: * - 'documenso-template' — render + sign in Documenso * - 'inapp' — render PDF locally (html / pdf_form / pdf_overlay), upload to Documenso * - 'upload' — admin-supplied PDF, upload to Documenso, auto-place signature fields * * The full implementation lands in PR6 once the wizard validator + new * template formats ship; PR1 only fixes the public surface. */ export async function createFromWizard( _portId: string, _data: unknown, _meta: AuditMeta, ): Promise { throw new Error('createFromWizard not yet implemented (Phase A PR6)'); } /** * Skeleton for the upload-driven creation path (PR6). * * Stores a port-uploaded PDF in MinIO via the files service, mirrors a row * into `documents` + `documentSigners`, calls Documenso `createDocument` * with the buffer, optionally calls `sendDocument` when `sendImmediately`. */ export async function createFromUpload( _portId: string, _data: unknown, _meta: AuditMeta, ): Promise { throw new Error('createFromUpload not yet implemented (Phase A PR6)'); }