feat(documents): create-document wizard MVP + service dispatch

Implements createFromWizard and createFromUpload service paths covering
the documenso-template, in-app, and upload pathways. Persists subject
FK, signers, watchers, and the per-document reminder controls
(remindersDisabled / reminderCadenceOverride) introduced in PR1. New
POST /api/v1/documents/wizard route and a functional /documents/new UI
with type/source/template/signers/reminders sections. Drag-handle
reorder, watcher autocomplete picker, and PDF preview defer to the
PR10 polish sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 02:43:00 +02:00
parent 2dc53842c0
commit d8f0cdd7d2
5 changed files with 656 additions and 36 deletions

View File

@@ -1159,35 +1159,169 @@ export async function removeDocumentWatcher(
}
/**
* Skeleton for the create-document wizard entry point (PR6).
* 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
* Dispatches across pathways:
* - 'documenso-template' — Documenso renders + signs from its own template
* - 'inapp' — render PDF locally from a CRM template, upload to Documenso
* - 'upload' — admin-supplied PDF, upload to Documenso (auto-place signature
* fields if `autoPlaceFields`)
*
* The full implementation lands in PR6 once the wizard validator + new
* template formats ship; PR1 only fixes the public surface.
* Persists the document, applies reminder overrides, attaches watchers, and
* triggers send when `sendImmediately`.
*/
import type { CreateDocumentWizardInput } from '@/lib/validators/documents';
export async function createFromWizard(
_portId: string,
_data: unknown,
_meta: AuditMeta,
portId: string,
data: CreateDocumentWizardInput,
meta: AuditMeta,
): Promise<typeof documents.$inferSelect> {
throw new Error('createFromWizard not yet implemented (Phase A PR6)');
if (data.source === 'upload') {
return createFromUpload(portId, data, meta);
}
if (!data.templateId) {
throw new ValidationError('templateId is required for template source');
}
const [doc] = await db
.insert(documents)
.values({
portId,
interestId: data.interestId ?? null,
reservationId: data.reservationId ?? null,
clientId: data.clientId ?? null,
companyId: data.companyId ?? null,
yachtId: data.yachtId ?? null,
documentType: data.documentType,
title: data.title,
notes: data.notes ?? null,
status: 'draft',
remindersDisabled: data.remindersDisabled,
reminderCadenceOverride: data.reminderCadenceOverride ?? null,
createdBy: meta.userId,
})
.returning();
if (!doc) throw new Error('Failed to insert document');
if (data.watchers.length > 0) {
await db.insert(documentWatchers).values(
data.watchers.map((userId) => ({
documentId: doc.id,
userId,
addedBy: meta.userId,
})),
);
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: doc.id,
newValue: {
documentType: doc.documentType,
title: doc.title,
pathway: data.pathway,
source: data.source,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc.id });
return doc;
}
/**
* 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`.
* Upload-driven creation path. Files-service integration + Documenso upload
* + auto-place signature fields land alongside the realapi PR (PR11). For
* PR6 we persist the document row + signers + watchers and leave the
* Documenso upload step to the existing sendForSigning flow on first send.
*/
export async function createFromUpload(
_portId: string,
_data: unknown,
_meta: AuditMeta,
portId: string,
data: CreateDocumentWizardInput,
meta: AuditMeta,
): Promise<typeof documents.$inferSelect> {
throw new Error('createFromUpload not yet implemented (Phase A PR6)');
if (!data.uploadedFileId) {
throw new ValidationError('uploadedFileId is required for upload source');
}
if (!data.signers || data.signers.length === 0) {
throw new ValidationError('signers are required for upload source');
}
const fileRecord = await db.query.files.findFirst({
where: and(eq(files.id, data.uploadedFileId), eq(files.portId, portId)),
});
if (!fileRecord) {
throw new NotFoundError('File');
}
const [doc] = await db
.insert(documents)
.values({
portId,
interestId: data.interestId ?? null,
reservationId: data.reservationId ?? null,
clientId: data.clientId ?? null,
companyId: data.companyId ?? null,
yachtId: data.yachtId ?? null,
documentType: data.documentType,
title: data.title,
notes: data.notes ?? null,
status: 'draft',
fileId: fileRecord.id,
remindersDisabled: data.remindersDisabled,
reminderCadenceOverride: data.reminderCadenceOverride ?? null,
createdBy: meta.userId,
})
.returning();
if (!doc) throw new Error('Failed to insert document');
await db.insert(documentSigners).values(
data.signers.map((s) => ({
documentId: doc.id,
signerName: s.signerName,
signerEmail: s.signerEmail,
signerRole: s.signerRole,
signingOrder: s.signingOrder,
status: 'pending' as const,
})),
);
if (data.watchers.length > 0) {
await db.insert(documentWatchers).values(
data.watchers.map((userId) => ({
documentId: doc.id,
userId,
addedBy: meta.userId,
})),
);
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: doc.id,
newValue: {
documentType: doc.documentType,
title: doc.title,
pathway: 'upload',
source: 'upload',
uploadedFileId: fileRecord.id,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: doc.id });
return doc;
}