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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user