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.

- `<ExternalEoiEditDialog>` 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 19:34:19 +02:00
parent 7881da675b
commit 235e0645cb
4 changed files with 553 additions and 2 deletions

View File

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