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;
}

View File

@@ -17,6 +17,58 @@ export const updateDocumentSchema = z.object({
status: z.enum(DOCUMENT_STATUSES).optional(),
});
const wizardSignerSchema = z.object({
signerName: z.string().min(1),
signerEmail: z.string().email(),
signerRole: z.enum(['client', 'sales', 'approver', 'developer', 'other']),
signingOrder: z.number().int().min(1),
});
export const createDocumentWizardSchema = z
.object({
source: z.enum(['template', 'upload']).default('template'),
templateId: z.string().optional(),
uploadedFileId: z.string().optional(),
documentType: z.enum(DOCUMENT_TYPES),
title: z.string().min(1).max(200),
notes: z.string().optional(),
interestId: z.string().optional(),
reservationId: z.string().optional(),
clientId: z.string().optional(),
companyId: z.string().optional(),
yachtId: z.string().optional(),
signers: z.array(wizardSignerSchema).optional(),
signingMode: z.enum(['sequential', 'parallel']).default('sequential'),
pathway: z.enum(['documenso-template', 'inapp', 'upload']).default('documenso-template'),
watchers: z.array(z.string()).default([]),
reminderCadenceOverride: z.number().int().min(1).max(365).nullable().optional(),
remindersDisabled: z.boolean().default(false),
autoPlaceFields: z.boolean().default(true),
sendImmediately: z.boolean().default(true),
})
.refine(
(d) =>
[d.interestId, d.reservationId, d.clientId, d.companyId, d.yachtId].filter(Boolean).length ===
1,
{ message: 'Exactly one subject (interest/reservation/client/company/yacht) is required' },
)
.refine((d) => d.source !== 'template' || Boolean(d.templateId), {
path: ['templateId'],
message: 'templateId is required when source=template',
})
.refine((d) => d.source !== 'upload' || Boolean(d.uploadedFileId), {
path: ['uploadedFileId'],
message: 'uploadedFileId is required when source=upload',
});
export type CreateDocumentWizardInput = z.infer<typeof createDocumentWizardSchema>;
export const documentsHubTabs = [
'all',
'awaiting_them',