Files
pn-new-crm/src/lib/services/external-eoi.service.ts

466 lines
18 KiB
TypeScript
Raw Normal View History

/**
* External EOI upload - for EOIs signed outside Documenso (paper signing,
* different e-sign vendor, signed in person, etc).
*
* Creates BOTH the document row AND the signed-file record in one shot,
* then advances the interest stage. Distinct from the existing
* uploadSignedManually flow which augments a document row that was
* already created via the Documenso pathway.
*/
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>
2026-05-21 19:34:19 +02:00
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
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';
import { getStorageBackend } from '@/lib/storage';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
import { canonicalizeStage, type PipelineStage } from '@/lib/constants';
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>
2026-05-21 19:34:19 +02:00
import { CodedError, ConflictError, 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;
/** PDF bytes. */
fileData: { buffer: Buffer; originalName: string; mimeType: string; size: number };
/** Free-text title for the document row. Defaults to "External EOI - <date>". */
title?: string;
/** When the signing actually happened (the date on the paper / contract). */
signedAt?: Date;
/**
* 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;
/**
* Optional: id of an active generated EOI to cancel as part of this
* upload. When set, the service voids the Documenso envelope and flips
* the prior local row to `status='cancelled'` BEFORE creating the new
* external-EOI doc so a deal carries one canonical EOI at any moment.
* Idempotent already-cancelled / wrong-port ids are ignored.
*/
cancelActiveDocumentId?: string;
meta: AuditMeta;
}
export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
const { interestId, portId, fileData, meta } = input;
if (fileData.size <= 0) throw new ValidationError('Empty file');
if (fileData.size > 25 * 1024 * 1024) {
throw new ValidationError('File too large (max 25 MB)');
}
if (
fileData.mimeType !== 'application/pdf' &&
!fileData.originalName.toLowerCase().endsWith('.pdf')
) {
throw new ValidationError('Only PDF uploads are accepted for signed EOIs');
}
const interest = await db.query.interests.findFirst({
where: eq(interests.id, interestId),
});
if (!interest || interest.portId !== portId) throw new NotFoundError('Interest');
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) throw new NotFoundError('Port');
// Replace-mode: cancel the prior active EOI before creating the new one.
// Done OUTSIDE the upload transaction so a Documenso void error doesn't
// block the upload the rep has already photographed/scanned. cancelDocument
// is idempotent on status='cancelled' so concurrent calls are safe.
let cancelledDocumentId: string | null = null;
if (input.cancelActiveDocumentId) {
try {
const { cancelDocument } = await import('@/lib/services/documents.service');
await cancelDocument(input.cancelActiveDocumentId, portId, meta, {
reason: 'Replaced by external upload',
cancelMode: 'delete',
});
cancelledDocumentId = input.cancelActiveDocumentId;
} catch {
// Swallow — the rep meant to replace, but failing to cancel the prior
// doc shouldn't lose the upload. The audit log on the new doc still
// captures the rep's intent via metadata.replacedDocumentId.
}
}
const documentId = crypto.randomUUID();
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'eoi-signed', documentId, fileId, 'pdf');
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
// Upload to storage FIRST so we have a stable key for the DB rows,
// then commit all four DB writes in one transaction. If the tx fails
// the storage object becomes orphaned (S3 isn't transactional) but
// the DB stays clean - orphan reaper handles those.
await (
await getStorageBackend()
).put(storagePath, fileData.buffer, {
contentType: 'application/pdf',
sizeBytes: fileData.size,
});
const title =
input.title ?? `External EOI - ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
const result = await db.transaction(async (tx) => {
const [fileRecord] = await tx
.insert(files)
.values({
portId,
clientId: interest.clientId,
filename: fileData.originalName,
originalName: fileData.originalName,
mimeType: 'application/pdf',
sizeBytes: String(fileData.size),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
if (!fileRecord) {
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'External EOI file insert returned no row',
});
}
const [doc] = await tx
.insert(documents)
.values({
id: documentId,
portId,
interestId,
clientId: interest.clientId,
yachtId: interest.yachtId,
documentType: 'eoi',
title,
status: 'completed',
isManualUpload: true,
signedFileId: fileRecord.id,
notes: input.notes ?? null,
createdBy: meta.userId,
})
.returning();
if (!doc) {
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'External EOI document insert returned no row',
});
}
// 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);
}
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
await tx.insert(documentEvents).values({
documentId: doc.id,
eventType: 'completed',
eventData: {
isManualUpload: true,
external: true,
signerNames: input.signerNames ?? (input.signatories ?? []).map((s) => s.name),
signatories: input.signatories ?? null,
signedAt: signedAtMoment.toISOString(),
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
fileId: fileRecord.id,
},
});
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
// Two concerns to keep separate:
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
// 1. Document metadata - always write dateEoiSigned, eoiStatus, and
// eoiDocStatus from the upload. Even if the rep already advanced
// the stage manually, the paper signing event needs a recorded
// date + doc-status badge so downstream surfaces (SkipAheadBanner,
// milestone strip, EOI merge fields, signing-progress chip) reflect
// reality. Honour an existing dateEoiSigned - covers the case where
// the rep is uploading evidence for an event whose date was already
// backfilled.
// 2. Stage advance - in the 7-stage pipeline (PIPELINE_STAGES), every
// pre-EOI stage (enquiry / qualified / nurturing) should flip to
// 'eoi' on signing. The doc-status 'signed' carries the within-stage
// sub-state. Bypasses canTransitionStage because the operator just
// brought concrete proof. Idempotent at the 'eoi' / 'reservation' /
// 'deposit_paid' / 'contract' stages (stays put).
const stageBeforeAdvance = interest.pipelineStage as PipelineStage | null | undefined;
const canonicalStage = canonicalizeStage(stageBeforeAdvance);
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
const shouldAdvanceStage =
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
canonicalStage === 'enquiry' ||
canonicalStage === 'qualified' ||
canonicalStage === 'nurturing';
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
await tx
.update(interests)
.set({
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(),
eoiStatus: 'signed',
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
eoiDocStatus: 'signed',
...(shouldAdvanceStage ? { pipelineStage: 'eoi' as const } : {}),
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
updatedAt: new Date(),
})
.where(eq(interests.id, interestId));
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
return {
documentId: doc.id,
fileId: fileRecord.id,
stageChanged: shouldAdvanceStage,
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
newStage: shouldAdvanceStage ? ('eoi' as const) : canonicalStage,
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
};
});
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
const { documentId: docId, fileId: fId, stageChanged, newStage } = result;
void createAuditLog({
portId,
userId: meta.userId,
action: 'create',
entityType: 'document',
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
entityId: docId,
metadata: {
kind: 'external_eoi_upload',
interestId,
title,
signerNames: input.signerNames ?? [],
signedAt: (input.signedAt ?? new Date()).toISOString(),
fileSizeBytes: fileData.size,
...(cancelledDocumentId ? { replacedDocumentId: cancelledDocumentId } : {}),
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
emitToRoom(`port:${portId}`, 'document:completed', { documentId: docId });
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
// Berth rules engine: a manually-uploaded external EOI is still an
// EOI-signed event for the rules that watch this trigger (e.g.
// auto-mark the primary berth Under Offer). Fire via dynamic import
// to dodge the circular dep between berth-rules-engine and the
// interest services.
try {
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
await evaluateRule('eoi_signed', interestId, portId, meta);
} catch {
// Swallow - rules engine failures should never block the upload
fix(uat-batch-2): external-EOI five-bug bundle (a/b/c/d) + presign filename override Tackles the linked B4 #5 findings on the external-EOI flow. Item (e) [Edit metadata affordance per row] is deferred to a later wave so it can share infra with the broader signing-flow rework. - (a) lying toast: uploadExternallySignedEoi now returns { stageChanged, newStage }. Client toasts conditionally so a Reservation+ deal that uploads paper-signing evidence no longer claims the stage advanced. - (b) View downloads instead of previewing: SignedPdfActions takes an onView callback; InterestEoiTab lifts a single FilePreviewDialog and passes the callback down. Click-View opens the in-app preview rather than the presigned URL (which the storage backend served as attachment). - (c) UUID filename on download: getDownloadUrl now passes the canonical filename through presignDownloadUrl; S3 backend adds a response-content-disposition override (filename + UTF-8 filename*) to the presign. Filesystem backend already passed it through. - (d) Discarded dateEoiSigned: external-eoi service splits document- metadata writes (always — dateEoiSigned, eoiStatus='signed') from stage advance (gated on past-EOI). Also fires evaluateRule('eoi_signed') so berth-rules stay in sync when an EOI is filed manually. - Default title for external-EOI dialog now derives "External EOI — <Client> — <berth range> — <date>" via the existing formatBerthRange helper; rep can override. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:01:35 +02:00
// that the rep has already completed end-to-end. The orphan-reaper
// path doesn't apply; a missed rule evaluation is a soft failure.
}
return { documentId: docId, fileId: fId, stageChanged, newStage, cancelledDocumentId };
}
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>
2026-05-21 19:34:19 +02:00
// ─── 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
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>
2026-05-21 19:34:19 +02:00
* 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
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>
2026-05-21 19:34:19 +02:00
* 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 };
}