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

View File

@@ -2,11 +2,14 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Pen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FileGrid } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -21,6 +24,7 @@ interface ClientFilesTabProps {
export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading } = usePaginatedQuery<FileRow>({
@@ -64,12 +68,28 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
return (
<div className="space-y-4">
<PermissionGate resource="files" action="upload">
<FileUploadZone
clientId={clientId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
}}
/>
<div className="space-y-3">
<FileUploadZone
clientId={clientId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
}}
/>
<PermissionGate resource="documents" action="send_for_signing">
<div className="flex items-center justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setUploadForSigningOpen(true)}
className="gap-2"
>
<Pen className="h-4 w-4" aria-hidden />
Upload &amp; send for signature
</Button>
</div>
</PermissionGate>
</div>
</PermissionGate>
<FileGrid
@@ -88,6 +108,17 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
{uploadForSigningOpen && (
<UploadForSigningDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
interestId={null}
documentType="generic"
entity={{ type: 'client', id: clientId }}
/>
)}
{confirmDialog}
</div>
);

View File

@@ -2,11 +2,14 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Pen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FileGrid } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -21,6 +24,7 @@ interface CompanyFilesTabProps {
export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const { confirm, dialog: confirmDialog } = useConfirmation();
const { data, isLoading } = usePaginatedQuery<FileRow>({
@@ -64,12 +68,28 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
return (
<div className="space-y-4">
<PermissionGate resource="files" action="upload">
<FileUploadZone
companyId={companyId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files', { companyId }] });
}}
/>
<div className="space-y-3">
<FileUploadZone
companyId={companyId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files', { companyId }] });
}}
/>
<PermissionGate resource="documents" action="send_for_signing">
<div className="flex items-center justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setUploadForSigningOpen(true)}
className="gap-2"
>
<Pen className="h-4 w-4" aria-hidden />
Upload &amp; send for signature
</Button>
</div>
</PermissionGate>
</div>
</PermissionGate>
<FileGrid
@@ -88,6 +108,17 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
{uploadForSigningOpen && (
<UploadForSigningDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
interestId={null}
documentType="generic"
entity={{ type: 'company', id: companyId }}
/>
)}
{confirmDialog}
</div>
);

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { useQueryClient } from '@tanstack/react-query';
import { ChevronDown, FileSignature, Plus, Upload } from 'lucide-react';
import { ChevronDown, FileSignature, Pen, Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -20,6 +20,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
/**
* Dropdown that replaces the bare "+ New document" button on the documents
@@ -55,6 +56,7 @@ export function NewDocumentMenu({
size = 'default',
}: NewDocumentMenuProps) {
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const queryClient = useQueryClient();
return (
@@ -77,6 +79,15 @@ export function NewDocumentMenu({
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setUploadForSigningOpen(true)} className="gap-2 py-2.5">
<Pen className="h-4 w-4" aria-hidden />
<div className="flex flex-col">
<span>Upload &amp; send for signature</span>
<span className="text-xs text-muted-foreground">
Drop a PDF, place fields, send via Documenso
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem asChild className="gap-2 py-2.5">
<Link href={`/${portSlug}/documents/new`}>
<FileSignature className="h-4 w-4" aria-hidden />
@@ -123,6 +134,17 @@ export function NewDocumentMenu({
/>
</DialogContent>
</Dialog>
{uploadForSigningOpen && (
<UploadForSigningDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
interestId={null}
documentType="generic"
entity={entityType && entityId ? { type: entityType, id: entityId } : undefined}
folderId={folderId ?? null}
/>
)}
</>
);
}

View File

@@ -139,17 +139,37 @@ const RECIPIENT_COLORS = [
'rgb(20 184 166)', // teal-500
];
export interface UploadForSigningEntity {
type: 'client' | 'company' | 'yacht';
id: string;
/** Display label only — used in the dialog header so the rep can
* see which entity the doc will be filed under. */
label?: string;
}
interface UploadForSigningDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interestId: string;
/** Pre-set the document type - the parent (EOI/Contract/Reservation
* tab) decides which to upload. EOI here is the upload-draft path;
* the template-driven generate flow lives on EoiGenerateDialog. */
documentType: 'eoi' | 'contract' | 'reservation_agreement';
/** Required for eoi / contract / reservation_agreement (the pipeline
* side effects need it). MUST be null for documentType='generic' —
* in that case the upload routes through the generic endpoint and
* optionally files the doc against the supplied `entity`. */
interestId: string | null;
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
/** Optional: client name/email to prefill the first recipient.
* When omitted the dialog fetches from the interest. */
* When omitted the dialog fetches from the interest (interest-scoped
* flows) or leaves the recipient blank (generic flow). */
clientPrefill?: { name: string; email: string };
/** Generic flow only: routes the resulting file/document row to the
* entity's FK column + auto-files it into the entity's system
* folder. Ignored when `interestId` is set. */
entity?: UploadForSigningEntity;
/** Generic flow only: explicit folder placement (e.g. rep is
* uploading from within a Documents Hub folder). */
folderId?: string | null;
/** Generic flow only: caller-supplied success hook. Receives the
* new documentId and can invalidate caches / show a toast. */
onCreated?: (result: { documentId: string }) => void;
}
export function UploadForSigningDialog({
@@ -158,18 +178,25 @@ export function UploadForSigningDialog({
interestId,
documentType,
clientPrefill,
entity,
folderId,
onCreated,
}: UploadForSigningDialogProps) {
// Re-mount the body on every open so all state resets cleanly. Same
// pattern as hard-delete-dialog (set-state-in-effect avoidance).
if (!open) return null;
const draftKey = interestId ?? entity?.id ?? 'generic';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
<DialogBody
key={`${interestId}:${documentType}`}
key={`${draftKey}:${documentType}`}
interestId={interestId}
documentType={documentType}
clientPrefill={clientPrefill}
entity={entity}
folderId={folderId ?? null}
onCreated={onCreated}
onClose={() => onOpenChange(false)}
/>
</DialogContent>
@@ -186,8 +213,8 @@ type Step = 'select-file' | 'configure-recipients' | 'place-fields';
* contract upload AND reservation upload in the same browser session
* without them clobbering each other.
*/
function draftStorageKey(interestId: string, documentType: string): string {
return `pn-crm.upload-for-signing.draft.v1:${interestId}:${documentType}`;
function draftStorageKey(scopeId: string, documentType: string): string {
return `pn-crm.upload-for-signing.draft.v1:${scopeId}:${documentType}`;
}
interface PersistedDraft {
@@ -200,10 +227,10 @@ interface PersistedDraft {
savedAt: string;
}
function loadDraft(interestId: string, documentType: string): PersistedDraft | null {
function loadDraft(scopeId: string, documentType: string): PersistedDraft | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(draftStorageKey(interestId, documentType));
const raw = window.localStorage.getItem(draftStorageKey(scopeId, documentType));
if (!raw) return null;
const parsed = JSON.parse(raw) as PersistedDraft;
// Defensive shape check - drop drafts that look malformed rather
@@ -221,19 +248,19 @@ function loadDraft(interestId: string, documentType: string): PersistedDraft | n
}
}
function saveDraft(interestId: string, documentType: string, draft: PersistedDraft): void {
function saveDraft(scopeId: string, documentType: string, draft: PersistedDraft): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(draftStorageKey(interestId, documentType), JSON.stringify(draft));
window.localStorage.setItem(draftStorageKey(scopeId, documentType), JSON.stringify(draft));
} catch {
// localStorage may throw on private mode or quota - swallow.
}
}
function clearDraft(interestId: string, documentType: string): void {
function clearDraft(scopeId: string, documentType: string): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(draftStorageKey(interestId, documentType));
window.localStorage.removeItem(draftStorageKey(scopeId, documentType));
} catch {
// ignore
}
@@ -243,19 +270,30 @@ function DialogBody({
interestId,
documentType,
clientPrefill,
entity,
folderId,
onCreated,
onClose,
}: {
interestId: string;
documentType: 'eoi' | 'contract' | 'reservation_agreement';
interestId: string | null;
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
clientPrefill?: { name: string; email: string };
entity?: UploadForSigningEntity;
folderId?: string | null;
onCreated?: (result: { documentId: string }) => void;
onClose: () => void;
}) {
// Draft scope: interestId when scoped to a deal, otherwise the
// entity id (so the rep can have one in-flight upload per entity),
// else 'generic' for the root Documents Hub flow.
const draftScopeId = interestId ?? entity?.id ?? 'generic';
// Hydrate from the persisted draft once on mount. The `key` prop on
// the parent re-mounts this body on every open, so this useState
// initializer runs once per dialog session.
const initialDraft = useMemo(
() => loadDraft(interestId, documentType),
[interestId, documentType],
() => loadDraft(draftScopeId, documentType),
[draftScopeId, documentType],
);
const [step, setStep] = useState<Step>(initialDraft?.step ?? 'select-file');
@@ -275,7 +313,9 @@ function DialogBody({
? 'Sales Contract'
: documentType === 'eoi'
? 'Expression of Interest'
: 'Reservation Agreement';
: documentType === 'reservation_agreement'
? 'Reservation Agreement'
: 'Document';
// Defaults endpoint - drives the developer/approver prefill.
const { data: defaults } = useQuery<{ data: SigningDefaults }>({
@@ -285,7 +325,7 @@ function DialogBody({
// Interest endpoint - used to prefill the client recipient when the
// caller didn't supply one. Cached so the same dialog open/reopen
// hits the cache.
// hits the cache. Skipped entirely on the generic path (no interest).
const { data: interestData } = useQuery<{
data: { client: { fullName: string; email: string | null } };
}>({
@@ -294,7 +334,7 @@ function DialogBody({
apiFetch<{ data: { client: { fullName: string; email: string | null } } }>(
`/api/v1/interests/${interestId}`,
),
enabled: !clientPrefill,
enabled: Boolean(interestId) && !clientPrefill,
});
/**
@@ -381,11 +421,11 @@ function DialogBody({
fields.length > 0 ||
invitationMessage.length > 0;
if (!hasProgress) {
clearDraft(interestId, documentType);
clearDraft(draftScopeId, documentType);
return;
}
const now = new Date().toISOString();
saveDraft(interestId, documentType, {
saveDraft(draftScopeId, documentType, {
step,
title,
recipients,
@@ -399,10 +439,10 @@ function DialogBody({
return () => {
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
};
}, [step, title, recipients, fields, invitationMessage, interestId, documentType]);
}, [step, title, recipients, fields, invitationMessage, draftScopeId, documentType]);
function discardDraft() {
clearDraft(interestId, documentType);
clearDraft(draftScopeId, documentType);
setTitle('');
setRecipients([]);
setFields([]);
@@ -479,7 +519,23 @@ function DialogBody({
})),
),
);
const res = await fetch(`/api/v1/interests/${interestId}/upload-for-signing`, {
// Generic envelopes go to the cross-cutting endpoint; the
// entity / folder context piggybacks on the form so the file
// row lands under the right system folder. Interest-scoped
// flows keep their dedicated route so the pipeline-stage
// advance + doc-status flip side effects fire.
if (interestId) {
if (documentType === 'generic') {
throw new Error('Generic documentType requires interestId=null');
}
} else {
if (entity) form.append('entity', JSON.stringify({ type: entity.type, id: entity.id }));
if (folderId) form.append('folderId', folderId);
}
const endpoint = interestId
? `/api/v1/interests/${interestId}/upload-for-signing`
: `/api/v1/upload-for-signing`;
const res = await fetch(endpoint, {
method: 'POST',
body: form,
credentials: 'include',
@@ -506,11 +562,14 @@ function DialogBody({
);
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
void res;
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' });
if (onCreated && res?.data?.documentId) {
onCreated({ documentId: res.data.documentId });
}
// Clear the draft on successful submission - the in-flight upload
// is now an actual document; the localStorage shouldn't keep its
// shadow around.
clearDraft(interestId, documentType);
clearDraft(draftScopeId, documentType);
onClose();
},
onError: (err) => toastError(err, 'Upload failed'),

View File

@@ -87,7 +87,20 @@ export interface CustomDocumentRecipient {
}
export interface UploadDocumentForSigningArgs {
interestId: string;
/** Optional interest the doc is filed under. Required for eoi /
* contract / reservation_agreement (their pipeline-stage side
* effects need it); MUST be null for 'generic' (cross-cutting
* envelopes that aren't tied to a sales deal). */
interestId: string | null;
/** Optional entity context — drives the auto-filed folder + the
* file-row FK. Used by the 'generic' path when there's no interest
* to derive the client from. Ignored when `interestId` is set
* (the service resolves the client off the interest itself). */
entity?: { type: 'client' | 'company' | 'yacht'; id: string } | null;
/** Optional explicit folder placement. When set, overrides the
* entity-derived folder (e.g. rep dropped the upload into a
* specific subfolder from the Documents Hub). */
folderId?: string | null;
portId: string;
portSlug: string;
documentType: CustomDocumentType;
@@ -125,6 +138,8 @@ export async function uploadDocumentForSigning(
): Promise<UploadDocumentForSigningResult> {
const {
interestId,
entity,
folderId: explicitFolderId,
portId,
portSlug,
documentType,
@@ -137,6 +152,21 @@ export async function uploadDocumentForSigning(
meta,
} = args;
// Generic envelopes (no pipeline-stage advance / no interest) MUST
// come in with interestId=null; non-generic types MUST carry an
// interest. Reject the mismatch here so the rest of the function can
// assume the right invariant.
if (documentType !== 'generic' && !interestId) {
throw new ValidationError(
`${documentType} document requires an interestId — only 'generic' documents can be uploaded without one`,
);
}
if (documentType === 'generic' && interestId) {
throw new ValidationError(
'Generic documents cannot carry an interestId — use a type-specific document type instead',
);
}
// ─── Validation ──────────────────────────────────────────────────
if (recipients.length === 0) {
throw new ValidationError('At least one recipient is required');
@@ -175,10 +205,15 @@ export async function uploadDocumentForSigning(
}
// ─── Tenant guard ────────────────────────────────────────────────
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) throw new NotFoundError('Interest');
// Non-generic types resolve their interest (and derive the client
// from there). Generic types skip the interest lookup; entity FK
// routing comes from the caller-supplied `entity` arg.
const interest = interestId
? await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
})
: null;
if (interestId && !interest) throw new NotFoundError('Interest');
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) throw new NotFoundError('Port');
@@ -200,10 +235,14 @@ export async function uploadDocumentForSigning(
: documentType === 'eoi'
? 'eoi-source'
: 'signed-source';
// Storage path groups by interestId when we have one; for generic
// uploads the entity id (or a synthetic 'unfiled' bucket) keeps the
// namespace tidy.
const storageGroupId = interestId ?? entity?.id ?? 'unfiled';
const sourceStoragePath = buildStoragePath(
portSlug,
storageCategory,
interestId,
storageGroupId,
sourceFileId,
'pdf',
);
@@ -214,11 +253,16 @@ export async function uploadDocumentForSigning(
sizeBytes: pdfBuffer.length,
});
// Look up the interest's primary client so the auto-filed folder
// ends up under the right entity subfolder. Falls back to root when
// the chain has no resolvable owner.
let entityFolderId: string | null = null;
if (interest.clientId) {
// Folder placement priority:
// 1. Caller-supplied `folderId` (rep dropped the upload into a
// specific Documents Hub folder).
// 2. Interest's primary client folder (legacy path for
// EOI/contract/reservation tabs).
// 3. Caller-supplied entity (generic path: client/company/yacht
// doc tab originated the upload).
// 4. Root (fallback).
let entityFolderId: string | null = explicitFolderId ?? null;
if (entityFolderId === null && interest?.clientId) {
try {
const folder = await ensureEntityFolder(portId, 'client', interest.clientId, 'system');
entityFolderId = folder.id;
@@ -229,12 +273,38 @@ export async function uploadDocumentForSigning(
);
}
}
if (entityFolderId === null && entity) {
try {
const folder = await ensureEntityFolder(portId, entity.type, entity.id, 'system');
entityFolderId = folder.id;
} catch (err) {
logger.warn(
{ err, entity },
'ensureEntityFolder failed for generic upload entity - filing at root',
);
}
}
// Derive the entity-FK fields on the `files` row from whichever
// source we have. Interest-derived takes priority; otherwise the
// generic `entity` arg maps to its corresponding column.
const fileEntityFKs: {
clientId: string | null;
companyId: string | null;
yachtId: string | null;
} = {
clientId: interest?.clientId ?? (entity?.type === 'client' ? entity.id : null),
companyId: entity?.type === 'company' ? entity.id : null,
yachtId: entity?.type === 'yacht' ? entity.id : null,
};
const [sourceFileRecord] = await db
.insert(files)
.values({
portId,
clientId: interest.clientId,
clientId: fileEntityFKs.clientId,
companyId: fileEntityFKs.companyId,
yachtId: fileEntityFKs.yachtId,
folderId: entityFolderId,
filename,
originalName: filename,
@@ -259,7 +329,9 @@ export async function uploadDocumentForSigning(
.values({
portId,
interestId,
clientId: interest.clientId,
clientId: fileEntityFKs.clientId,
companyId: fileEntityFKs.companyId,
yachtId: fileEntityFKs.yachtId,
fileId: sourceFileRecord.id,
documentType,
title,
@@ -412,7 +484,7 @@ export async function uploadDocumentForSigning(
// per-type doc-status flip - they're cross-cutting envelopes that
// happen to be filed against this interest. The eoi / contract /
// reservation_agreement branches keep their existing side effects.
if (documentType !== 'generic') {
if (documentType !== 'generic' && interestId) {
const stageByType: Record<
Exclude<CustomDocumentType, 'generic'>,
'eoi' | 'contract' | 'reservation'