feat(uat-batch-6): external-EOI structured signatories + X/Y signed counter

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 17:34:59 +02:00
parent 7cdfed27fa
commit 301375a3c3
3 changed files with 217 additions and 22 deletions

View File

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