feat(documents): universal upload-with-fields UI wiring (B3 #11)
Backend foundations were already in place ('generic' CustomDocumentType,
storage-path routing). This wires the UI surface across Documents Hub +
entity file tabs.
- UploadForSigningDialog: interestId now string | null; new entity?,
folderId?, onCreated? props. Generic path POSTs to new endpoint
/api/v1/upload-for-signing; interest-scoped paths unchanged.
- uploadDocumentForSigning service: interestId nullable; skips interest
lookup, pipeline-stage advance, doc-status flip on the generic path.
Routes file FK + auto-filed folder via either interest.clientId or the
caller-supplied entity. Validation enforces the matching invariant
(generic must be interestId=null, type-specific must carry one).
- New menu item in NewDocumentMenu ("Upload & send for signature") on
Documents Hub root + folder views.
- Upload & send-for-signature button on ClientFilesTab + CompanyFilesTab,
gated by documents.send_for_signing.
Existing unit tests for the service still pass (validation paths unchanged).
This commit is contained in:
160
src/app/api/v1/upload-for-signing/route.ts
Normal file
160
src/app/api/v1/upload-for-signing/route.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import {
|
||||
uploadDocumentForSigning,
|
||||
type CustomDocumentType,
|
||||
type CustomRecipientRole,
|
||||
} from '@/lib/services/custom-document-upload.service';
|
||||
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
|
||||
|
||||
/**
|
||||
* Generic upload-for-signing endpoint — used by the Documents Hub and
|
||||
* entity doc-tab "Send file for signature" buttons where the doc isn't
|
||||
* tied to an interest's sales pipeline. The interest-scoped sibling
|
||||
* (/api/v1/interests/[id]/upload-for-signing) is still the path for
|
||||
* EOI / Contract / Reservation flows so the pipeline side effects fire.
|
||||
*
|
||||
* documentType is locked to 'generic' here. Optional `entity` +
|
||||
* `folderId` route the file to the right place on the Documents Hub.
|
||||
*
|
||||
* Permission: documents.send_for_signing — same gate as the
|
||||
* interest-scoped flow.
|
||||
*/
|
||||
|
||||
const recipientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['SIGNER', 'APPROVER', 'CC']),
|
||||
signingOrder: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const fieldSchema = z.object({
|
||||
recipientIndex: z.number().int().nonnegative(),
|
||||
type: z.enum([
|
||||
'SIGNATURE',
|
||||
'FREE_SIGNATURE',
|
||||
'INITIALS',
|
||||
'DATE',
|
||||
'EMAIL',
|
||||
'NAME',
|
||||
'TEXT',
|
||||
'NUMBER',
|
||||
'CHECKBOX',
|
||||
'DROPDOWN',
|
||||
'RADIO',
|
||||
]),
|
||||
pageNumber: z.number().int().positive(),
|
||||
pageX: z.number().min(0).max(100),
|
||||
pageY: z.number().min(0).max(100),
|
||||
pageWidth: z.number().positive().max(100),
|
||||
pageHeight: z.number().positive().max(100),
|
||||
fieldMeta: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const entitySchema = z.object({
|
||||
type: z.enum(['client', 'company', 'yacht']),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const MAX_PDF_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function parseJsonField<T>(raw: unknown, schema: z.ZodType<T>, label: string): T {
|
||||
if (typeof raw !== 'string') {
|
||||
throw new ValidationError(`Missing or non-string '${label}' field`);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new ValidationError(`'${label}' is not valid JSON`);
|
||||
}
|
||||
const result = schema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new ValidationError(`'${label}' validation failed: ${result.error.issues[0]?.message}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'send_for_signing', async (req, ctx) => {
|
||||
try {
|
||||
const form = await req.formData();
|
||||
|
||||
// ─── file ──────────────────────────────────────────────────
|
||||
const file = form.get('file');
|
||||
if (!file || !(file instanceof File)) {
|
||||
throw new ValidationError('Missing file');
|
||||
}
|
||||
if (file.size > MAX_PDF_BYTES) {
|
||||
throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
|
||||
}
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
if (!isPdfMagic(buffer)) {
|
||||
throw new ValidationError('Uploaded file is not a PDF');
|
||||
}
|
||||
|
||||
// ─── scalar fields ─────────────────────────────────────────
|
||||
const title = z.string().min(1).max(255).parse(form.get('title'));
|
||||
const invitationMessageRaw = form.get('invitationMessage');
|
||||
const invitationMessage =
|
||||
typeof invitationMessageRaw === 'string'
|
||||
? z.string().max(1000).parse(invitationMessageRaw)
|
||||
: null;
|
||||
|
||||
// Optional entity / folder routing.
|
||||
const entityRaw = form.get('entity');
|
||||
const entity =
|
||||
typeof entityRaw === 'string' && entityRaw.length > 0
|
||||
? parseJsonField(entityRaw, entitySchema, 'entity')
|
||||
: null;
|
||||
const folderIdRaw = form.get('folderId');
|
||||
const folderId =
|
||||
typeof folderIdRaw === 'string' && folderIdRaw.length > 0 ? folderIdRaw : null;
|
||||
|
||||
// ─── JSON fields ───────────────────────────────────────────
|
||||
const recipients = parseJsonField(
|
||||
form.get('recipients'),
|
||||
z.array(recipientSchema).min(1).max(20),
|
||||
'recipients',
|
||||
);
|
||||
const fields = parseJsonField(
|
||||
form.get('fields'),
|
||||
z.array(fieldSchema).min(1).max(200),
|
||||
'fields',
|
||||
);
|
||||
|
||||
const result = await uploadDocumentForSigning({
|
||||
interestId: null,
|
||||
entity,
|
||||
folderId,
|
||||
portId: ctx.portId,
|
||||
portSlug: ctx.portSlug,
|
||||
documentType: 'generic' satisfies CustomDocumentType,
|
||||
title,
|
||||
pdfBuffer: buffer,
|
||||
filename: file.name || 'document.pdf',
|
||||
recipients: recipients.map((r) => ({
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
role: r.role as CustomRecipientRole,
|
||||
signingOrder: r.signingOrder,
|
||||
})),
|
||||
fields,
|
||||
invitationMessage,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: result }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user