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

@@ -8,7 +8,7 @@
* already created via the Documenso pathway.
*/
import { eq } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
@@ -18,7 +18,7 @@ import { env } from '@/lib/env';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
/** A single signatory on an externally-signed document. */
@@ -245,3 +245,186 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
return { documentId: docId, fileId: fId, stageChanged, newStage };
}
// ─── Edit metadata on a previously-uploaded external EOI ─────────────────────
/** Signatory row in an edit payload. `id` distinguishes update vs insert. */
export interface ExternalEoiSignatoryEdit {
/** Present on existing rows; omitted to insert a new signer. */
id?: string;
name: string;
email: string;
role: 'client' | 'developer' | 'rep' | 'witness' | 'cc';
}
export interface ExternalEoiMetadataPatch {
documentId: string;
portId: string;
/** Undefined = leave unchanged; null is not allowed (title is NOT NULL). */
title?: string;
/** Undefined = leave unchanged; null clears `interests.dateEoiSigned`. */
signedAt?: Date | null;
/** Undefined = leave unchanged; '' clears the column. */
notes?: string;
/**
* Full replacement set when present. Rows with an id are UPDATEd; rows
* without are INSERTed; existing rows whose id isn't in the array are
* DELETEd. CC entries are stored but excluded from the X/Y signed
* count (status stays signed=signedAt to match the upload-time
* pre-stamp).
*/
signatories?: ExternalEoiSignatoryEdit[];
meta: AuditMeta;
}
/**
* Update title / notes / signed-date / signatories on a previously-uploaded
* external EOI. Refuses to touch Documenso-managed documents because their
* signer rows are the vendor's source of truth — any edit would drift from
* the upstream envelope.
*
* Mirrors the upload service's invariants:
* - `signedAt` updates BOTH `documents` events and `interests.dateEoiSigned`
* (and the per-signer `signedAt` stamp for the structured signatories).
* - Document_signers writes are full-replacement when `signatories` is
* present (insert / update / delete by id-presence). Same shape as the
* upload-time insert: CC entries persisted but not counted as signers.
*
* Stage advance is NOT re-evaluated — that fires once at upload and shouldn't
* be reversed by a metadata edit. If the rep needs to roll a stage back,
* they do it through the stage-change UI directly.
*/
export async function updateExternalEoiMetadata(input: ExternalEoiMetadataPatch) {
const { documentId, portId, meta } = input;
const document = await db.query.documents.findFirst({
where: and(eq(documents.id, documentId), eq(documents.portId, portId)),
});
if (!document) throw new NotFoundError('Document');
if (!document.isManualUpload) {
throw new ConflictError(
'Only manually-uploaded documents can have their metadata edited. Documenso-managed documents inherit their signers and signing date from the upstream envelope.',
);
}
if (document.documentType !== 'eoi') {
// The form only knows EOI shape today; widen later when other doc
// types grow their own external-upload pathways.
throw new ConflictError(
'Metadata edit is currently supported only for EOI documents. Open a ticket if you need it for contracts or reservations.',
);
}
// Capture before-state for the audit log.
const beforeSigners = await db.query.documentSigners.findMany({
where: eq(documentSigners.documentId, documentId),
});
const result = await db.transaction(async (tx) => {
// 1) Patch the document row itself.
const docPatch: Partial<typeof documents.$inferInsert> = { updatedAt: new Date() };
if (input.title !== undefined) docPatch.title = input.title;
if (input.notes !== undefined) docPatch.notes = input.notes === '' ? null : input.notes;
await tx.update(documents).set(docPatch).where(eq(documents.id, documentId));
// 2) Sync the interest's `dateEoiSigned` when signedAt is being
// edited. Honour the upload-side rule: a metadata edit IS the
// canonical source for the date, so we overwrite even when the
// column already has a value (the rep is presumably fixing it).
if (input.signedAt !== undefined && document.interestId) {
await tx
.update(interests)
.set({
dateEoiSigned: input.signedAt,
updatedAt: new Date(),
})
.where(eq(interests.id, document.interestId));
}
// 3) Replace the signatories list when provided. Mirror the upload
// invariants: status='signed' on every non-CC row (the doc has
// already been signed externally), signedAt stamped from the
// edit's effective signing date.
if (input.signatories !== undefined) {
const signedAtMoment =
input.signedAt !== undefined
? (input.signedAt ?? new Date())
: (beforeSigners[0]?.signedAt ?? new Date());
const submittedIds = new Set(input.signatories.filter((s) => s.id).map((s) => s.id!));
const existingIds = beforeSigners.map((s) => s.id);
const toDelete = existingIds.filter((id) => !submittedIds.has(id));
if (toDelete.length > 0) {
await tx.delete(documentSigners).where(inArray(documentSigners.id, toDelete));
}
for (let i = 0; i < input.signatories.length; i++) {
const s = input.signatories[i]!;
const isSigner = s.role !== 'cc';
if (s.id && existingIds.includes(s.id)) {
await tx
.update(documentSigners)
.set({
signerName: s.name,
signerEmail: s.email,
signerRole: s.role,
signingOrder: i + 1,
status: isSigner ? 'signed' : 'pending',
signedAt: isSigner ? signedAtMoment : null,
})
.where(eq(documentSigners.id, s.id));
} else {
await tx.insert(documentSigners).values({
documentId,
signerName: s.name,
signerEmail: s.email,
signerRole: s.role,
signingOrder: i + 1,
status: isSigner ? 'signed' : 'pending',
signedAt: isSigner ? signedAtMoment : null,
});
}
}
}
// 4) Trail in document_events so the activity timeline reflects the
// edit alongside the original 'completed' row.
await tx.insert(documentEvents).values({
documentId,
eventType: 'metadata_updated',
eventData: {
editedBy: meta.userId,
fields: {
title: input.title !== undefined,
signedAt: input.signedAt !== undefined,
notes: input.notes !== undefined,
signatories: input.signatories !== undefined,
},
},
});
return { documentId };
});
void createAuditLog({
portId,
userId: meta.userId,
action: 'update',
entityType: 'document',
entityId: documentId,
metadata: {
kind: 'external_eoi_metadata_edit',
fieldsChanged: {
title: input.title !== undefined,
signedAt: input.signedAt !== undefined,
notes: input.notes !== undefined,
signatories: input.signatories !== undefined,
},
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:updated', { documentId: result.documentId });
return { documentId: result.documentId };
}