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:
75
src/app/api/v1/documents/[id]/metadata/route.ts
Normal file
75
src/app/api/v1/documents/[id]/metadata/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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<DetailResponse>({
|
||||
@@ -303,6 +308,11 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{doc.isManualUpload && doc.documentType === 'eoi' ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMetadata(true)}>
|
||||
<Pencil className="mr-1.5 h-4 w-4" aria-hidden /> Edit metadata
|
||||
</Button>
|
||||
) : null}
|
||||
{isInFlight ? (
|
||||
<Button size="sm" variant="outline" onClick={handleCancel} disabled={isCancelling}>
|
||||
<X className="mr-1.5 h-4 w-4" /> Cancel
|
||||
@@ -364,6 +374,33 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
{confirmDialog}
|
||||
{doc.isManualUpload && doc.documentType === 'eoi' && editingMetadata ? (
|
||||
<ExternalEoiEditDialog
|
||||
open
|
||||
onOpenChange={setEditingMetadata}
|
||||
documentId={doc.id}
|
||||
initial={{
|
||||
title: doc.title,
|
||||
// Use the earliest signedAt across signers as the canonical date
|
||||
// (matches the upload service's `signedAtMoment` semantics). Falls
|
||||
// back to the doc's createdAt slice if no signer is stamped.
|
||||
signedAt: (() => {
|
||||
const stamps = signers
|
||||
.map((s) => s.signedAt)
|
||||
.filter((d): d is string => !!d)
|
||||
.sort();
|
||||
return (stamps[0] ?? doc.createdAt).slice(0, 10);
|
||||
})(),
|
||||
notes: doc.notes ?? '',
|
||||
signatories: signers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.signerName,
|
||||
email: s.signerEmail,
|
||||
role: (s.signerRole as 'client' | 'developer' | 'rep' | 'witness' | 'cc') ?? 'client',
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
256
src/components/documents/external-eoi-edit-dialog.tsx
Normal file
256
src/components/documents/external-eoi-edit-dialog.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Save, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
type SignatoryRole = 'client' | 'developer' | 'rep' | 'witness' | 'cc';
|
||||
|
||||
const ROLE_LABELS: Record<SignatoryRole, string> = {
|
||||
client: 'Client',
|
||||
developer: 'Developer',
|
||||
rep: 'Rep',
|
||||
witness: 'Witness',
|
||||
cc: 'CC (no signing)',
|
||||
};
|
||||
|
||||
interface SignatoryRow {
|
||||
/** Existing rows carry the document_signers.id; new rows are added without
|
||||
* one and the service treats them as inserts. */
|
||||
id?: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: SignatoryRole;
|
||||
}
|
||||
|
||||
interface InitialState {
|
||||
title: string;
|
||||
/** YYYY-MM-DD slice — the DatePicker treats it as ISO date. */
|
||||
signedAt: string;
|
||||
notes: string;
|
||||
signatories: SignatoryRow[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
documentId: string;
|
||||
initial: InitialState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing external-EOI document's metadata (title, signed
|
||||
* date, notes, signatories). Backed by `PATCH /api/v1/documents/[id]/metadata`,
|
||||
* which refuses on Documenso-managed docs — so the caller (detail page)
|
||||
* already gates rendering on `isManualUpload`.
|
||||
*
|
||||
* Mirrors the upload-side dialog's signatory shape so the on-screen
|
||||
* affordances feel identical to the rep.
|
||||
*/
|
||||
export function ExternalEoiEditDialog({ open, onOpenChange, documentId, initial }: Props) {
|
||||
// State is initialised once per mount; the parent guarantees a fresh
|
||||
// mount on every open by only rendering this component when the
|
||||
// dialog is open. That avoids a setState-in-effect re-hydration
|
||||
// pattern (banned by lint) — the dialog's open lifecycle IS the
|
||||
// initialisation trigger.
|
||||
const qc = useQueryClient();
|
||||
const [title, setTitle] = useState(initial.title);
|
||||
const [signedAt, setSignedAt] = useState(initial.signedAt);
|
||||
const [notes, setNotes] = useState(initial.notes);
|
||||
const [signatories, setSignatories] = useState<SignatoryRow[]>(initial.signatories);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmed = signatories.map((s) => ({
|
||||
...(s.id ? { id: s.id } : {}),
|
||||
name: s.name.trim(),
|
||||
email: s.email.trim(),
|
||||
role: s.role,
|
||||
}));
|
||||
// Block invalid rows client-side so the rep doesn't get a generic
|
||||
// 400 they have to map back to a row.
|
||||
const invalid = trimmed.find((s) => !s.name || !s.email);
|
||||
if (invalid) {
|
||||
throw new Error('Every signatory needs a name + email.');
|
||||
}
|
||||
await apiFetch(`/api/v1/documents/${documentId}/metadata`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
title: title.trim(),
|
||||
signedAt,
|
||||
notes,
|
||||
signatories: trimmed,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Metadata updated');
|
||||
void qc.invalidateQueries({ queryKey: ['document-detail', documentId] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toastError(err);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit document metadata</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adjust the title, signing date, notes, and signatories on this externally-signed EOI.
|
||||
The PDF itself stays put. This only edits the recorded metadata.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="edit-eoi-title">Title</Label>
|
||||
<Input
|
||||
id="edit-eoi-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Date signed</Label>
|
||||
<div className="mt-1">
|
||||
<DatePicker value={signedAt} onChange={setSignedAt} toDate={new Date()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Signatories</Label>
|
||||
<div className="space-y-2">
|
||||
{signatories.map((s, i) => (
|
||||
<div key={s.id ?? `new-${i}`} className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={s.name}
|
||||
onChange={(e) =>
|
||||
setSignatories((rows) =>
|
||||
rows.map((r, idx) => (idx === i ? { ...r, name: e.target.value } : r)),
|
||||
)
|
||||
}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={s.email}
|
||||
onChange={(e) =>
|
||||
setSignatories((rows) =>
|
||||
rows.map((r, idx) => (idx === i ? { ...r, email: e.target.value } : r)),
|
||||
)
|
||||
}
|
||||
className="flex-[2] min-w-0"
|
||||
/>
|
||||
<Select
|
||||
value={s.role}
|
||||
onValueChange={(v) =>
|
||||
setSignatories((rows) =>
|
||||
rows.map((r, idx) => (idx === i ? { ...r, role: v as SignatoryRole } : r)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-36 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(ROLE_LABELS) as SignatoryRole[]).map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{ROLE_LABELS[r]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSignatories((rows) => rows.filter((_, idx) => idx !== i))}
|
||||
aria-label="Remove signatory"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSignatories((rows) => [...rows, { name: '', email: '', role: 'client' }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-4 mr-1.5" aria-hidden />
|
||||
Add signatory
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CC entries are recorded but excluded from the X / Y signed badge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="edit-eoi-notes">Notes</Label>
|
||||
<Textarea
|
||||
id="edit-eoi-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Where / how this EOI was signed"
|
||||
rows={2}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending || !title.trim()}>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" aria-hidden />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" aria-hidden />
|
||||
)}
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user