From d8f0cdd7d2be34aad8c6dabc58f1479e777b8a1e Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 28 Apr 2026 02:43:00 +0200 Subject: [PATCH] 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) --- .../[portSlug]/documents/new/page.tsx | 18 +- src/app/api/v1/documents/wizard/route.ts | 24 + .../documents/create-document-wizard.tsx | 424 ++++++++++++++++++ src/lib/services/documents.service.ts | 174 ++++++- src/lib/validators/documents.ts | 52 +++ 5 files changed, 656 insertions(+), 36 deletions(-) create mode 100644 src/app/api/v1/documents/wizard/route.ts create mode 100644 src/components/documents/create-document-wizard.tsx diff --git a/src/app/(dashboard)/[portSlug]/documents/new/page.tsx b/src/app/(dashboard)/[portSlug]/documents/new/page.tsx index 7dff734..a8cdb48 100644 --- a/src/app/(dashboard)/[portSlug]/documents/new/page.tsx +++ b/src/app/(dashboard)/[portSlug]/documents/new/page.tsx @@ -1,6 +1,4 @@ -import Link from 'next/link'; -import { Button } from '@/components/ui/button'; -import { PageHeader } from '@/components/shared/page-header'; +import { CreateDocumentWizard } from '@/components/documents/create-document-wizard'; interface PageProps { params: Promise<{ portSlug: string }>; @@ -8,17 +6,5 @@ interface PageProps { export default async function NewDocumentPage({ params }: PageProps) { const { portSlug } = await params; - return ( -
- - Back to documents - - } - /> -
- ); + return ; } diff --git a/src/app/api/v1/documents/wizard/route.ts b/src/app/api/v1/documents/wizard/route.ts new file mode 100644 index 0000000..a6e64e1 --- /dev/null +++ b/src/app/api/v1/documents/wizard/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createFromWizard } from '@/lib/services/documents.service'; +import { createDocumentWizardSchema } from '@/lib/validators/documents'; + +export const POST = withAuth( + withPermission('documents', 'create', async (req, ctx) => { + try { + const body = await parseBody(req, createDocumentWizardSchema); + const doc = await createFromWizard(ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: doc }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx new file mode 100644 index 0000000..cadd6ed --- /dev/null +++ b/src/components/documents/create-document-wizard.tsx @@ -0,0 +1,424 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { PageHeader } from '@/components/shared/page-header'; +import { apiFetch } from '@/lib/api/client'; +import { DOCUMENT_TYPES } from '@/lib/constants'; + +const SIGNER_ROLES = ['client', 'sales', 'approver', 'developer', 'other'] as const; + +const SUBJECT_TYPES = [ + { key: 'interest', label: 'Interest', field: 'interestId' as const }, + { key: 'reservation', label: 'Reservation', field: 'reservationId' as const }, + { key: 'client', label: 'Client', field: 'clientId' as const }, + { key: 'company', label: 'Company', field: 'companyId' as const }, + { key: 'yacht', label: 'Yacht', field: 'yachtId' as const }, +] as const; + +interface SignerRow { + signerName: string; + signerEmail: string; + signerRole: (typeof SIGNER_ROLES)[number]; + signingOrder: number; +} + +interface CreateDocumentWizardProps { + portSlug: string; +} + +export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) { + const router = useRouter(); + + const [source, setSource] = useState<'template' | 'upload'>('template'); + const [pathway, setPathway] = useState<'documenso-template' | 'inapp' | 'upload'>( + 'documenso-template', + ); + const [templateId, setTemplateId] = useState(''); + const [uploadedFileId, setUploadedFileId] = useState(''); + const [documentType, setDocumentType] = useState<(typeof DOCUMENT_TYPES)[number]>('eoi'); + const [title, setTitle] = useState(''); + const [notes, setNotes] = useState(''); + + const [subjectType, setSubjectType] = useState<(typeof SUBJECT_TYPES)[number]['key']>('interest'); + const [subjectId, setSubjectId] = useState(''); + + const [signers, setSigners] = useState([ + { signerName: '', signerEmail: '', signerRole: 'client', signingOrder: 1 }, + ]); + const [signingMode, setSigningMode] = useState<'sequential' | 'parallel'>('sequential'); + + const [reminderMode, setReminderMode] = useState<'default' | 'override' | 'disabled'>('default'); + const [reminderDays, setReminderDays] = useState(7); + + const [submitting, setSubmitting] = useState(false); + + const subjectField = SUBJECT_TYPES.find((s) => s.key === subjectType)!.field; + + const setSourceAndPathway = (next: 'template' | 'upload'): void => { + setSource(next); + if (next === 'upload') { + setPathway('upload'); + } else if (pathway === 'upload') { + setPathway('documenso-template'); + } + }; + + const updateSigner = (idx: number, patch: Partial): void => { + setSigners((current) => current.map((s, i) => (i === idx ? { ...s, ...patch } : s))); + }; + + const addSigner = (): void => { + setSigners((current) => [ + ...current, + { + signerName: '', + signerEmail: '', + signerRole: 'other', + signingOrder: current.length + 1, + }, + ]); + }; + + const removeSigner = (idx: number): void => { + setSigners((current) => + current.filter((_, i) => i !== idx).map((s, i) => ({ ...s, signingOrder: i + 1 })), + ); + }; + + const handleSubmit = async (): Promise => { + if (!title.trim()) { + toast.error('Title is required'); + return; + } + if (!subjectId.trim()) { + toast.error(`Provide a ${subjectType} id`); + return; + } + if (source === 'template' && !templateId.trim()) { + toast.error('Pick a template'); + return; + } + if (source === 'upload' && !uploadedFileId.trim()) { + toast.error('Provide an uploaded file id'); + return; + } + const cleanSigners = signers.filter((s) => s.signerEmail.trim() && s.signerName.trim()); + if (source === 'upload' && cleanSigners.length === 0) { + toast.error('Upload path requires at least one signer'); + return; + } + + setSubmitting(true); + try { + const body: Record = { + source, + pathway, + documentType, + title: title.trim(), + notes: notes.trim() || undefined, + [subjectField]: subjectId.trim(), + signingMode, + watchers: [], + autoPlaceFields: true, + sendImmediately: false, + remindersDisabled: reminderMode === 'disabled', + }; + + if (source === 'template') body.templateId = templateId.trim(); + if (source === 'upload') { + body.uploadedFileId = uploadedFileId.trim(); + body.signers = cleanSigners; + } else if (cleanSigners.length > 0) { + body.signers = cleanSigners; + } + + if (reminderMode === 'override') { + body.reminderCadenceOverride = reminderDays; + } + + const res = await apiFetch<{ data: { id: string } }>('/api/v1/documents/wizard', { + method: 'POST', + body, + }); + toast.success('Document created'); + router.push(`/${portSlug}/documents/${res.data.id}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to create document'); + setSubmitting(false); + } + }; + + return ( +
+ + + Back + + + } + /> + +
+
+

+ Source +

+
+ + + + {source === 'template' ? ( + <> +
+ + +
+
+ + setTemplateId(e.target.value)} + placeholder="Template UUID" + /> +
+ + ) : ( +
+ + setUploadedFileId(e.target.value)} + placeholder="File UUID from /api/v1/files upload" + /> +

+ Upload via the existing file uploader, then paste the returned id here. +

+
+ )} +
+
+ +
+

+ Document +

+
+
+ + +
+
+ + setTitle(e.target.value)} /> +
+
+ + setNotes(e.target.value)} /> +
+
+ + setSubjectId(e.target.value)} + placeholder={`${subjectType} id`} + /> +
+
+
+ +
+
+

+ Signers +

+ +
+
    + {signers.map((s, idx) => ( +
  • + + #{s.signingOrder} + + updateSigner(idx, { signerName: e.target.value })} + placeholder="Name" + /> + updateSigner(idx, { signerEmail: e.target.value })} + placeholder="Email" + /> + + +
  • + ))} +
+
+ + +
+
+ +
+

+ Reminders +

+
+ + + +
+
+
+ +
+ + +
+
+ ); +} diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 93d19af..1514ce4 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -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 { - 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 { - 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; } diff --git a/src/lib/validators/documents.ts b/src/lib/validators/documents.ts index 7248510..29f98d6 100644 --- a/src/lib/validators/documents.ts +++ b/src/lib/validators/documents.ts @@ -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; + export const documentsHubTabs = [ 'all', 'awaiting_them',