merge: PR6 — create-document wizard MVP (Phase A)
This commit is contained in:
@@ -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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="New document"
|
||||
description="The create-document wizard ships in PR6 of the Phase A rollout."
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/documents`}>Back to documents</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <CreateDocumentWizard portSlug={portSlug} />;
|
||||
}
|
||||
|
||||
24
src/app/api/v1/documents/wizard/route.ts
Normal file
24
src/app/api/v1/documents/wizard/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
424
src/components/documents/create-document-wizard.tsx
Normal file
424
src/components/documents/create-document-wizard.tsx
Normal file
@@ -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<SignerRow[]>([
|
||||
{ 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<SignerRow>): 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<void> => {
|
||||
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<string, unknown> = {
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="New document"
|
||||
description="Generate, attach, and send a document for signing."
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/documents`}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-md border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Source
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={source === 'template'}
|
||||
onChange={() => setSourceAndPathway('template')}
|
||||
/>
|
||||
<span>Generate from a template</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={source === 'upload'}
|
||||
onChange={() => setSourceAndPathway('upload')}
|
||||
/>
|
||||
<span>Upload a finished PDF</span>
|
||||
</label>
|
||||
|
||||
{source === 'template' ? (
|
||||
<>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Label className="text-xs">Pathway</Label>
|
||||
<Select value={pathway} onValueChange={(v) => setPathway(v as typeof pathway)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="documenso-template">Documenso renders + signs</SelectItem>
|
||||
<SelectItem value="inapp">Render in CRM, sign via Documenso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Template id</Label>
|
||||
<Input
|
||||
value={templateId}
|
||||
onChange={(e) => setTemplateId(e.target.value)}
|
||||
placeholder="Template UUID"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Uploaded file id</Label>
|
||||
<Input
|
||||
value={uploadedFileId}
|
||||
onChange={(e) => setUploadedFileId(e.target.value)}
|
||||
placeholder="File UUID from /api/v1/files upload"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload via the existing file uploader, then paste the returned id here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Document
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={documentType}
|
||||
onValueChange={(v) => setDocumentType(v as typeof documentType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Title</Label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Internal notes</Label>
|
||||
<Input value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-[max-content_1fr] gap-2">
|
||||
<Select
|
||||
value={subjectType}
|
||||
onValueChange={(v) => setSubjectType(v as typeof subjectType)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUBJECT_TYPES.map((s) => (
|
||||
<SelectItem key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(e.target.value)}
|
||||
placeholder={`${subjectType} id`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border bg-white p-4 lg:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Signers
|
||||
</h2>
|
||||
<Button size="sm" variant="outline" onClick={addSigner}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" /> Add signer
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{signers.map((s, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="grid grid-cols-[2.5rem_1fr_1fr_8rem_2rem] items-center gap-2 text-sm"
|
||||
>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
#{s.signingOrder}
|
||||
</span>
|
||||
<Input
|
||||
value={s.signerName}
|
||||
onChange={(e) => updateSigner(idx, { signerName: e.target.value })}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<Input
|
||||
value={s.signerEmail}
|
||||
onChange={(e) => updateSigner(idx, { signerEmail: e.target.value })}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<Select
|
||||
value={s.signerRole}
|
||||
onValueChange={(v) =>
|
||||
updateSigner(idx, { signerRole: v as SignerRow['signerRole'] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SIGNER_ROLES.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{r}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove signer"
|
||||
onClick={() => removeSigner(idx)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<Label className="text-xs">Signing mode</Label>
|
||||
<Select
|
||||
value={signingMode}
|
||||
onValueChange={(v) => setSigningMode(v as typeof signingMode)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sequential">Sequential</SelectItem>
|
||||
<SelectItem value="parallel">Parallel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border bg-white p-4 lg:col-span-2">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Reminders
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={reminderMode === 'default'}
|
||||
onChange={() => setReminderMode('default')}
|
||||
/>
|
||||
Use template default
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={reminderMode === 'override'}
|
||||
onChange={() => setReminderMode('override')}
|
||||
/>
|
||||
Override:
|
||||
<Input
|
||||
type="number"
|
||||
className="ml-1 w-20"
|
||||
min={1}
|
||||
max={365}
|
||||
value={reminderDays}
|
||||
onChange={(e) => setReminderDays(Number(e.target.value))}
|
||||
onFocus={() => setReminderMode('override')}
|
||||
/>
|
||||
days
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
checked={reminderMode === 'disabled'}
|
||||
onChange={() => setReminderMode('disabled')}
|
||||
/>
|
||||
Disable reminders for this document
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${portSlug}/documents`}>Cancel</Link>
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create document'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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