From 301375a3c31131e4b2c2729008dc3f519284dd74 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 17:34:59 +0200 Subject: [PATCH] feat(uat-batch-6): external-EOI structured signatories + X/Y signed counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the freetext CSV signer-names field with a structured recipient editor (name / email / role per row). Service now persists each non-CC signatory as a `document_signers` row pre-stamped `status='signed'` so the document-detail "X / Y signed" badge counts correctly for manually-uploaded EOIs. - ExternalEoiInput gains a structured `signatories` field; legacy `signerNames` retained for back-compat. Role enum: `client | developer | rep | witness | cc`. - uploadExternallySignedEoi inserts `document_signers` rows for every non-CC entry inside the existing transaction. - documentEvents.completed event records both shapes for full audit fidelity. - POST /api/v1/interests/[id]/external-eoi parses the `signatories` JSON multipart field defensively; malformed payloads fall back to signerNames. - Dialog UI: per-row Name / Email / Role inputs with add / remove. Seeds from interest's clientName + clientPrimaryEmail via a signatoriesOverride/null pattern (React-Compiler safe — no setState-in-effect). tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/interests/[id]/external-eoi/route.ts | 35 ++++ .../interests/external-eoi-upload-dialog.tsx | 158 ++++++++++++++++-- src/lib/services/external-eoi.service.ts | 46 ++++- 3 files changed, 217 insertions(+), 22 deletions(-) diff --git a/src/app/api/v1/interests/[id]/external-eoi/route.ts b/src/app/api/v1/interests/[id]/external-eoi/route.ts index cadf5a5c..265f1be7 100644 --- a/src/app/api/v1/interests/[id]/external-eoi/route.ts +++ b/src/app/api/v1/interests/[id]/external-eoi/route.ts @@ -35,6 +35,40 @@ export const POST = withAuth( .map((s) => s.trim()) .filter(Boolean) : undefined; + // Newer clients post a structured `signatories` JSON array. Parse + // safely and ignore malformed payloads (downstream falls back to + // signerNames). + const signatoriesRaw = (form.get('signatories') as string | null) ?? null; + let signatories: + | Array<{ + name: string; + email: string; + role: 'client' | 'developer' | 'rep' | 'witness' | 'cc'; + }> + | undefined; + if (signatoriesRaw) { + try { + const parsed = JSON.parse(signatoriesRaw) as unknown; + if (Array.isArray(parsed)) { + signatories = parsed + .map((row) => { + if (typeof row !== 'object' || row === null) return null; + const r = row as Record; + if (typeof r.name !== 'string' || typeof r.email !== 'string') return null; + const role = typeof r.role === 'string' ? r.role : 'client'; + if (!['client', 'developer', 'rep', 'witness', 'cc'].includes(role)) return null; + return { + name: r.name.trim(), + email: r.email.trim(), + role: role as 'client' | 'developer' | 'rep' | 'witness' | 'cc', + }; + }) + .filter((s): s is NonNullable => s !== null && !!s.name && !!s.email); + } + } catch { + // ignore malformed JSON + } + } const signedAtRaw = (form.get('signedAt') as string | null) ?? null; const signedAt = signedAtRaw ? new Date(signedAtRaw) : undefined; if (signedAt && Number.isNaN(signedAt.getTime())) { @@ -53,6 +87,7 @@ export const POST = withAuth( title, signedAt, signerNames, + signatories, notes, meta: { userId: ctx.userId, diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index 04db577b..675ae632 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Loader2, Upload } from 'lucide-react'; +import { Loader2, Plus, Trash2, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -10,10 +10,33 @@ import { DatePicker } from '@/components/ui/date-picker'; import { FileInputButton } from '@/components/ui/file-input-button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { formatBerthRange } from '@/lib/templates/berth-range'; + +type SignatoryRole = 'client' | 'developer' | 'rep' | 'witness' | 'cc'; + +interface SignatoryRow { + name: string; + email: string; + role: SignatoryRole; +} + +const ROLE_LABELS: Record = { + client: 'Client', + developer: 'Developer', + rep: 'Rep', + witness: 'Witness', + cc: 'CC (no signing)', +}; import { Dialog, DialogContent, @@ -35,21 +58,45 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc const [file, setFile] = useState(null); const [title, setTitle] = useState(''); const [signedAt, setSignedAt] = useState(() => new Date().toISOString().slice(0, 10)); - const [signerNames, setSignerNames] = useState(''); + // `null` means "rep hasn't touched the list yet — show the + // derived-from-interest seed". Once edited (add/remove/change), + // the explicit array takes over. Avoids a setState-in-effect that + // the React Compiler bans. + const [signatoriesOverride, setSignatoriesOverride] = useState(null); const [notes, setNotes] = useState(''); // Fetched on open to power the default title: // "External EOI — — YYYY-MM-DD". Without // this the file lands as just "External EOI - " which is // unscannable in any list when a port has multiple deals closing on - // the same day. - const { data: interestData } = useQuery<{ data: { clientName: string | null } }>({ + // the same day. Also drives auto-fill on signatory rows tagged + // role=client. + const { data: interestData } = useQuery<{ + data: { clientName: string | null; clientPrimaryEmail: string | null }; + }>({ queryKey: ['interests', interestId], queryFn: () => - apiFetch<{ data: { clientName: string | null } }>(`/api/v1/interests/${interestId}`), + apiFetch<{ data: { clientName: string | null; clientPrimaryEmail: string | null } }>( + `/api/v1/interests/${interestId}`, + ), enabled: open, staleTime: 60_000, }); + + // Compute the effective signatory list — when the rep hasn't touched + // anything, seed from the interest's client. Once they edit, the + // explicit override takes over. + const signatories: SignatoryRow[] = useMemo(() => { + if (signatoriesOverride !== null) return signatoriesOverride; + if (!interestData?.data) return []; + return [ + { + name: interestData.data.clientName ?? '', + email: interestData.data.clientPrimaryEmail ?? '', + role: 'client' as const, + }, + ]; + }, [signatoriesOverride, interestData]); const { data: berthsData } = useQuery<{ data: Array<{ mooringNumber: string | null }> }>({ queryKey: ['interests', interestId, 'berths'], queryFn: () => @@ -82,7 +129,14 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc const effectiveTitle = title.trim() || defaultTitle; if (effectiveTitle) form.append('title', effectiveTitle); if (signedAt) form.append('signedAt', signedAt); - if (signerNames) form.append('signerNames', signerNames); + const cleanSignatories = signatories + .map((s) => ({ name: s.name.trim(), email: s.email.trim(), role: s.role })) + .filter((s) => s.name && s.email); + if (cleanSignatories.length > 0) { + form.append('signatories', JSON.stringify(cleanSignatories)); + // Back-compat for any consumer that still reads signerNames. + form.append('signerNames', cleanSignatories.map((s) => s.name).join(', ')); + } if (notes) form.append('notes', notes); const res = await fetch(`/api/v1/interests/${interestId}/external-eoi`, { method: 'POST', @@ -109,7 +163,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc qc.invalidateQueries({ queryKey: ['documents'] }); setFile(null); setTitle(''); - setSignerNames(''); + setSignatoriesOverride(null); setNotes(''); onOpenChange(false); onSuccess?.(); @@ -160,16 +214,86 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc -
- - setSignerNames(e.target.value)} - placeholder="e.g. John Smith, Marina Director" - className="mt-1" - /> -

- Recorded in the audit trail alongside the document. +

+ +
+ {signatories.map((s, i) => ( +
+ + setSignatoriesOverride( + signatories.map((row, idx) => + idx === i ? { ...row, name: e.target.value } : row, + ), + ) + } + className="flex-1 min-w-0" + /> + + setSignatoriesOverride( + signatories.map((row, idx) => + idx === i ? { ...row, email: e.target.value } : row, + ), + ) + } + className="flex-[2] min-w-0" + /> + + +
+ ))} +
+ +

+ Recorded against the document for the audit trail and the signed-count badge. CC + recipients aren't counted as signatories.

diff --git a/src/lib/services/external-eoi.service.ts b/src/lib/services/external-eoi.service.ts index 57934d4d..08a7ce64 100644 --- a/src/lib/services/external-eoi.service.ts +++ b/src/lib/services/external-eoi.service.ts @@ -12,7 +12,7 @@ import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; -import { documents, documentEvents, files } from '@/lib/db/schema/documents'; +import { documents, documentEvents, documentSigners, files } from '@/lib/db/schema/documents'; import { ports } from '@/lib/db/schema/ports'; import { env } from '@/lib/env'; import { buildStoragePath } from '@/lib/minio'; @@ -21,6 +21,15 @@ import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { CodedError, NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; +/** A single signatory on an externally-signed document. */ +export interface ExternalSignatory { + name: string; + email: string; + /** Drives auto-fill on the dialog + downstream "Email copy" recipient + * list. `cc` recipients aren't signers but show up on send-out lists. */ + role: 'client' | 'developer' | 'rep' | 'witness' | 'cc'; +} + export interface ExternalEoiInput { interestId: string; portId: string; @@ -30,8 +39,14 @@ export interface ExternalEoiInput { title?: string; /** When the signing actually happened (the date on the paper / contract). */ signedAt?: Date; - /** Names of the people who signed (free-text — we don't manage signer - * identities for external sigs). Recorded in metadata. */ + /** + * Structured signatory list — preferred over the legacy `signerNames` + * CSV. When present, the service inserts one `document_signers` row + * per non-CC entry pre-stamped `status='signed'` so the + * "X / Y signed" badge renders correctly downstream. + */ + signatories?: ExternalSignatory[]; + /** Legacy CSV form kept for backwards compat with older callers. */ signerNames?: string[]; /** Free-text note (e.g. "signed in person at boat show"). */ notes?: string; @@ -123,14 +138,35 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) { }); } + // Backfill document_signers rows for the structured signatory list + // so the document-detail "X / Y signed" badge counts correctly. CC + // recipients aren't signatories — they're recipients of the email + // copy and don't show up in the signed-count denominator. + const signedAtMoment = input.signedAt ?? new Date(); + const signerRows = (input.signatories ?? []) + .filter((s) => s.role !== 'cc') + .map((s, i) => ({ + documentId: doc.id, + signerName: s.name, + signerEmail: s.email, + signerRole: s.role, + signingOrder: i + 1, + status: 'signed' as const, + signedAt: signedAtMoment, + })); + if (signerRows.length > 0) { + await tx.insert(documentSigners).values(signerRows); + } + await tx.insert(documentEvents).values({ documentId: doc.id, eventType: 'completed', eventData: { isManualUpload: true, external: true, - signerNames: input.signerNames ?? [], - signedAt: (input.signedAt ?? new Date()).toISOString(), + signerNames: input.signerNames ?? (input.signatories ?? []).map((s) => s.name), + signatories: input.signatories ?? null, + signedAt: signedAtMoment.toISOString(), fileId: fileRecord.id, }, });