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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user