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

@@ -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<string, unknown>;
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<typeof s> => 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,