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:
2026-05-23 01:01:52 +02:00
parent 221ae5784e
commit 5bd0e1ad9a
7 changed files with 432 additions and 56 deletions

View 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);
}
}),
);