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