From 235e0645cb7391d648e3ea76d5a2798b634a3ac8 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 19:34:19 +0200 Subject: [PATCH] feat(documents): edit-metadata UI for externally-uploaded EOIs External-EOI uploads previously had no edit path. Once the rep clicked Upload, the recorded title / signed-date / signatories / notes were stuck. Fixing a misspelled signer name or a wrong signing date meant re-uploading the whole document. - New service helper `updateExternalEoiMetadata` patches: documents.title, documents.notes interests.dateEoiSigned (when signedAt changes) document_signers (full-replacement by id-presence: rows with an id are UPDATEd, rows without are INSERTed, existing rows whose id isn't in the array are DELETEd) Mirrors the upload-time invariants. CC rows are stored but excluded from the X/Y signed count; non-CC rows pre-stamp `status='signed'` with the effective signedAt. Refuses to touch Documenso-managed docs (vendor owns their signer rows) or non-EOI types (form shape isn't widened yet) with ConflictError. - `PATCH /api/v1/documents/[id]/metadata` route uses strict zod schema + documents.edit permission. 204 on success; service throws surface as the normal errorResponse mapping. - `` mirrors the upload-dialog's signatory affordance (name + email + role + add/remove) plus title / signed date / notes. Title is required; remove rows via the trash icon. - Document detail page gains an "Edit metadata" button (Pencil icon) that renders only when `isManualUpload && documentType === 'eoi'`. Initial signing date derives from the earliest stamped signer's signedAt to match what the upload service writes. - Trails the edit in document_events as `metadata_updated` so the activity timeline distinguishes upload-time vs edit-time changes. Dialog state is initialised once per mount; the parent only renders the dialog while open so each open is a fresh mount (avoids setState-in-effect re-hydration banned by lint). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/v1/documents/[id]/metadata/route.ts | 75 +++++ src/components/documents/document-detail.tsx | 37 +++ .../documents/external-eoi-edit-dialog.tsx | 256 ++++++++++++++++++ src/lib/services/external-eoi.service.ts | 187 ++++++++++++- 4 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 src/app/api/v1/documents/[id]/metadata/route.ts create mode 100644 src/components/documents/external-eoi-edit-dialog.tsx diff --git a/src/app/api/v1/documents/[id]/metadata/route.ts b/src/app/api/v1/documents/[id]/metadata/route.ts new file mode 100644 index 00000000..d1db5016 --- /dev/null +++ b/src/app/api/v1/documents/[id]/metadata/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { updateExternalEoiMetadata } from '@/lib/services/external-eoi.service'; + +const signatorySchema = z.object({ + id: z.string().uuid().optional(), + name: z.string().min(1).max(200), + email: z.string().email(), + role: z.enum(['client', 'developer', 'rep', 'witness', 'cc']), +}); + +const patchSchema = z + .object({ + title: z.string().min(1).max(500).optional(), + signedAt: z + .string() + .datetime({ offset: true }) + .or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)) + .nullable() + .optional(), + notes: z.string().max(4000).optional(), + signatories: z.array(signatorySchema).max(20).optional(), + }) + .strict() + .refine( + (v) => + v.title !== undefined || + v.signedAt !== undefined || + v.notes !== undefined || + v.signatories !== undefined, + { message: 'At least one field must be provided' }, + ); + +/** + * PATCH /api/v1/documents/[id]/metadata + * + * Edits title / signed-date / notes / signatories on a previously-uploaded + * external (manually-uploaded) document. Service throws ConflictError for + * Documenso-managed docs (vendor owns their signers) and for non-EOI + * document types (form shape isn't widened yet). + */ +export const PATCH = withAuth( + withPermission('documents', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, patchSchema); + const signedAtValue = + body.signedAt === undefined + ? undefined + : body.signedAt === null + ? null + : new Date(body.signedAt); + await updateExternalEoiMetadata({ + documentId: params.id!, + portId: ctx.portId, + title: body.title, + signedAt: signedAtValue, + notes: body.notes, + signatories: body.signatories, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx index 265a272b..aab13941 100644 --- a/src/components/documents/document-detail.tsx +++ b/src/components/documents/document-detail.tsx @@ -15,6 +15,7 @@ import { Eye, FileText, Mail, + Pencil, Send, Trash2, UserPlus, @@ -32,6 +33,7 @@ import { useConfirmation } from '@/hooks/use-confirmation'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { SigningProgress } from '@/components/documents/signing-progress'; +import { ExternalEoiEditDialog } from '@/components/documents/external-eoi-edit-dialog'; import { Select, SelectContent, @@ -62,6 +64,8 @@ interface DetailDoc { companyId: string | null; createdAt: string; createdBy: string; + isManualUpload: boolean; + notes: string | null; } interface DetailSigner { @@ -130,6 +134,7 @@ interface DocumentDetailProps { export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { const router = useRouter(); const [isCancelling, setIsCancelling] = useState(false); + const [editingMetadata, setEditingMetadata] = useState(false); const { confirm, dialog: confirmDialog } = useConfirmation(); const { data, isLoading, error } = useQuery({ @@ -303,6 +308,11 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { ) : null} + {doc.isManualUpload && doc.documentType === 'eoi' ? ( + + ) : null} {isInFlight ? ( + + ))} + + +

+ CC entries are recorded but excluded from the X / Y signed badge. +

+ + +
+ +