Files
pn-new-crm/src/lib/services/document-templates.ts

937 lines
36 KiB
TypeScript
Raw Normal View History

import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
import { formatDate } from '@/lib/utils/format-date';
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
import { documentTemplates, documents, documentSigners, files } from '@/lib/db/schema/documents';
import type { File as DbFile, Document as DbDocument } from '@/lib/db/schema/documents';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env';
import { getCountryName } from '@/lib/i18n/countries';
import {
createDocument as documensoCreate,
sendDocument as documensoSend,
generateDocumentFromTemplate as documensoGenerateFromTemplate,
} from '@/lib/services/documenso-client';
feat(sales): admin-configurable EOI signers + richer timeline events 1. Per-port EOI signer config - New `eoi_signers` system_settings key (JSON: { developer, approver }, each `{ name, email }`). Settings UI exposes it under Admin → Settings. - getPortEoiSigners(portId) reads the setting with a typed validator; falls back to the legacy David Mizrahi / Abbie May defaults if the row is missing or malformed (so older ports keep working until an admin saves a value). - Both EOI generation pathways now read from the helper instead of hardcoded constants: * documenso-template path (generateAndSignViaDocumensoTemplate) * in-app PDF-fill path (generateAndSignViaInApp) 2. Timeline upgrades The interest detail Activity tab now distinguishes the new automation events that arrived with sessions 1+2: - Stage auto-advances (userId='system') get a small "Auto" pill and carry their reason into the description (e.g. "Stage advanced to EOI Signed (auto-advanced — EOI signed via Documenso)"). - outcome_set events show "Marked as Won" / "Marked as Lost — went to another marina" with optional reason; trophy/X icons. - outcome_cleared events show "Reopened to {stage}" with a refresh icon. - Document events humanized: "Document 'X' fully signed" instead of "Document X: completed". - Stage labels run through stageLabel() so the timeline shows the human label, not the enum key. - Timestamps switched to relative-time with full-date tooltip. - "by system" is rendered plainly (no longer the literal user-id). tsc clean. vitest 832/832 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:19:55 +02:00
import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload';
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { generateEoiPdfFromTemplate } from '@/lib/pdf/fill-eoi-form';
import { MERGE_FIELDS, type MergeFieldCatalog } from '@/lib/templates/merge-fields';
import { buildEoiContext } from '@/lib/services/eoi-context';
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
import {
applyEoiOverridesBeforeRender,
applyOverridesToContext,
persistDocumentOverrides,
type EoiOverridesInput,
type AppliedOverrides,
} from '@/lib/services/eoi-overrides.service';
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
import type {
CreateTemplateInput,
UpdateTemplateInput,
ListTemplatesInput,
GenerateInput,
GenerateAndSignInput,
} from '@/lib/validators/document-templates';
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── Merge Field Definitions ──────────────────────────────────────────────────
export function getMergeFields(): MergeFieldCatalog {
return MERGE_FIELDS;
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listTemplates(portId: string, query: ListTemplatesInput) {
const { page, limit, sort, order, search, templateType, isActive } = query;
const filters = [];
if (templateType) {
filters.push(eq(documentTemplates.templateType, templateType));
}
if (isActive !== undefined) {
filters.push(eq(documentTemplates.isActive, isActive));
}
const sortColumn =
sort === 'name'
? documentTemplates.name
: sort === 'templateType'
? documentTemplates.templateType
: sort === 'createdAt'
? documentTemplates.createdAt
: documentTemplates.updatedAt;
return buildListQuery({
table: documentTemplates,
portIdColumn: documentTemplates.portId,
portId,
idColumn: documentTemplates.id,
updatedAtColumn: documentTemplates.updatedAt,
searchColumns: [documentTemplates.name],
searchTerm: search,
filters,
sort: { column: sortColumn, direction: order },
page,
pageSize: limit,
});
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getTemplateById(id: string, portId: string) {
const template = await db.query.documentTemplates.findFirst({
where: eq(documentTemplates.id, id),
});
if (!template || template.portId !== portId) {
throw new NotFoundError('Document template');
}
return template;
}
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createTemplate(portId: string, data: CreateTemplateInput, meta: AuditMeta) {
const [template] = await db
.insert(documentTemplates)
.values({
portId,
name: data.name,
description: data.description ?? null,
templateType: data.templateType,
bodyHtml: data.bodyHtml,
mergeFields: data.mergeFields ?? [],
isActive: data.isActive ?? true,
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'documentTemplate',
entityId: template!.id,
newValue: { name: template!.name, templateType: template!.templateType },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'documentTemplate:created', { templateId: template!.id });
return template!;
}
// ─── Update ───────────────────────────────────────────────────────────────────
export async function updateTemplate(
id: string,
portId: string,
data: UpdateTemplateInput,
meta: AuditMeta,
) {
const existing = await getTemplateById(id, portId);
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
const [updated] = await db
.update(documentTemplates)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(documentTemplates.id, id), eq(documentTemplates.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'documentTemplate',
entityId: id,
oldValue: diff as Record<string, unknown>,
newValue: data as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'documentTemplate:updated', { templateId: id });
return updated!;
}
// ─── Delete ───────────────────────────────────────────────────────────────────
export async function deleteTemplate(id: string, portId: string, meta: AuditMeta) {
const existing = await getTemplateById(id, portId);
await db
.delete(documentTemplates)
.where(and(eq(documentTemplates.id, id), eq(documentTemplates.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'delete',
entityType: 'documentTemplate',
entityId: id,
oldValue: { name: existing.name, templateType: existing.templateType },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'documentTemplate:deleted', { templateId: id });
}
// ─── Resolve Template ─────────────────────────────────────────────────────────
/**
* Interpolates all {{entity.field}} tokens in the template body HTML.
* BR-140: Required merge fields with no value throw ValidationError.
*/
export async function resolveTemplate(
templateId: string,
context: {
clientId?: string;
interestId?: string;
berthId?: string;
portId: string;
},
): Promise<string> {
const template = await getTemplateById(templateId, context.portId);
// Build token→value map from context
const tokenMap: Record<string, string> = {};
// Date tokens
const now = new Date();
tokenMap['{{date.today}}'] = now.toLocaleDateString('en-GB');
tokenMap['{{date.year}}'] = String(now.getFullYear());
// Port tokens
const port = await db.query.ports.findFirst({ where: eq(ports.id, context.portId) });
if (port) {
tokenMap['{{port.name}}'] = port.name;
tokenMap['{{port.defaultCurrency}}'] = port.defaultCurrency;
}
// ─── EOI-style resolution ───────────────────────────────────────────────────
// If an interestId is provided, prefer the shared buildEoiContext payload so
// that yacht.*, company.*, owner.*, and berth.* tokens all resolve from the
// same denormalised snapshot the PDF/Documenso pipelines use.
// Falls back to the legacy path below if the interest isn't EOI-ready
// (missing yacht or berth), so non-EOI templates still work.
let eoiContextLoaded = false;
if (context.interestId) {
try {
const eoi = await buildEoiContext(context.interestId, context.portId);
eoiContextLoaded = true;
// Client tokens (from EoiContext)
tokenMap['{{client.fullName}}'] = eoi.client.fullName;
tokenMap['{{client.email}}'] = eoi.client.primaryEmail ?? '';
tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? '';
tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? '';
// Yacht tokens - `eoi.yacht` is null when no yacht is linked
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
// (Section 3 of the EOI is optional). Tokens render as empty strings
// in that case so the template still produces output.
tokenMap['{{yacht.name}}'] = eoi.yacht?.name ?? '';
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht?.hullNumber ?? '';
tokenMap['{{yacht.flag}}'] = eoi.yacht?.flag ?? '';
tokenMap['{{yacht.yearBuilt}}'] =
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
eoi.yacht?.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
tokenMap['{{yacht.lengthFt}}'] = eoi.yacht?.lengthFt ?? '';
tokenMap['{{yacht.widthFt}}'] = eoi.yacht?.widthFt ?? '';
tokenMap['{{yacht.draftFt}}'] = eoi.yacht?.draftFt ?? '';
tokenMap['{{yacht.lengthM}}'] = eoi.yacht?.lengthM ?? '';
tokenMap['{{yacht.widthM}}'] = eoi.yacht?.widthM ?? '';
tokenMap['{{yacht.draftM}}'] = eoi.yacht?.draftM ?? '';
// EoiContext doesn't expose the yacht.registration column - look it up
// separately (cheap, indexed fetch) so the token resolves when present.
try {
const interestRow = await db.query.interests.findFirst({
where: eq(interests.id, context.interestId),
columns: { yachtId: true },
});
if (interestRow?.yachtId) {
const yachtRow = await db.query.yachts.findFirst({
where: eq(yachts.id, interestRow.yachtId),
columns: { registration: true },
});
tokenMap['{{yacht.registration}}'] = yachtRow?.registration ?? '';
} else {
tokenMap['{{yacht.registration}}'] = '';
}
} catch {
tokenMap['{{yacht.registration}}'] = '';
}
// Company tokens (only populated when owner is a company)
tokenMap['{{company.name}}'] = eoi.company?.name ?? '';
tokenMap['{{company.legalName}}'] = eoi.company?.legalName ?? '';
tokenMap['{{company.taxId}}'] = eoi.company?.taxId ?? '';
tokenMap['{{company.billingAddress}}'] = eoi.company?.billingAddress ?? '';
// Owner tokens
tokenMap['{{owner.type}}'] = eoi.owner.type;
tokenMap['{{owner.name}}'] = eoi.owner.name;
tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? '';
// Berth tokens - also optional. Render empty when no berth is linked.
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
tokenMap['{{berth.mooringNumber}}'] = eoi.berth?.mooringNumber ?? '';
tokenMap['{{berth.area}}'] = eoi.berth?.area ?? '';
tokenMap['{{berth.lengthFt}}'] = eoi.berth?.lengthFt ?? '';
tokenMap['{{berth.price}}'] = eoi.berth?.price ?? '';
tokenMap['{{berth.priceCurrency}}'] = eoi.berth?.priceCurrency ?? '';
tokenMap['{{berth.tenureType}}'] = eoi.berth?.tenureType ?? '';
// Interest tokens
tokenMap['{{interest.stage}}'] = eoi.interest.stage;
tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? '';
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
tokenMap['{{interest.berthNumber}}'] = eoi.berth?.mooringNumber ?? '';
tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact
? eoi.interest.dateFirstContact.toLocaleDateString('en-GB')
: '';
tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
} catch (err) {
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
// buildEoiContext throws ValidationError when the EOI's required client
// fields (name/email/address - Section 2) are missing. For non-EOI
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
// templates (correspondence, welcome letters, etc.) those gates don't
// apply - fall through to the legacy resolution path below. Re-throw
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
// anything else.
if (
!(err instanceof ValidationError) ||
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
!/missing required client details|interest has no (yacht|berth)/i.test(err.message)
) {
throw err;
}
}
}
// ─── Legacy / non-EOI fallback ──────────────────────────────────────────────
// Client tokens from direct client lookup (welcome letters, correspondence,
// or EOI-flow clients where we still want client.source to resolve).
if (context.clientId) {
const client = await db.query.clients.findFirst({
where: eq(clients.id, context.clientId),
});
if (client && client.portId === context.portId) {
// Always resolve source from the DB - EoiContext doesn't carry it.
if (tokenMap['{{client.source}}'] === undefined) {
tokenMap['{{client.source}}'] = client.source ?? '';
}
// Only fill client.* tokens if the EOI path didn't already populate them.
if (!eoiContextLoaded) {
const contactList = await db.query.clientContacts.findMany({
where: eq(clientContacts.clientId, context.clientId),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
const emailContact = contactList.find((c) => c.channel === 'email');
const phoneContact = contactList.find(
(c) => c.channel === 'phone' || c.channel === 'whatsapp',
);
tokenMap['{{client.fullName}}'] = client.fullName ?? '';
tokenMap['{{client.email}}'] = emailContact?.value ?? '';
tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
tokenMap['{{client.nationality}}'] = client.nationalityIso
? getCountryName(client.nationalityIso, 'en')
: '';
}
}
}
// Interest tokens (legacy path - fills in fields EoiContext doesn't expose,
// like eoiStatus / dateEoiSigned / dateContractSigned, or populates the
// whole interest.* block when EOI resolution was skipped).
if (context.interestId) {
const interest = await db.query.interests.findFirst({
where: eq(interests.id, context.interestId),
});
if (interest && interest.portId === context.portId) {
if (!eoiContextLoaded) {
tokenMap['{{interest.stage}}'] = interest.pipelineStage ?? '';
tokenMap['{{interest.leadCategory}}'] = interest.leadCategory ?? '';
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
? formatDate(interest.dateFirstContact, 'date.medium', { fallback: '' })
: '';
feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul Major interest workflow expansion driven by the rapid-fire UX session. EOI / Contract / Reservation tabs replace the generic Documents tab when the deal is at the relevant stage — workspace pattern with active-doc hero, signing progress, paper-signed upload, and history strip. Stage- conditional visibility wired through interest-tabs.tsx so the tab set shrinks/expands as the deal moves through the pipeline. Contact log: per-interaction structured log (channel/direction/summary/ optional follow-up reminder). New `interest_contact_log` table + service + tab UI (timeline with channel-coded icons + compose dialog). auto-creates a reminder when followUpAt is set. Berth Interest milestone: first milestone in the OverviewTab's pipeline strip, completes the moment any berth is linked via the junction. Drives the "have we captured what they want?" sanity check for general_interest leads before they move to EOI. Stage-conditional milestones: past phases collapse into a one-liner strip, current phase expands, future phases hide behind a "Show upcoming" toggle. Inline stage picker now defers reason capture to an override-confirm view (only required for illegal transitions, not the default flow). Notes blob → threaded: dropped `interests.notes` column entirely; the threaded `interest_notes` table is the single source of truth. Latest- note teaser on Overview links into the dedicated Notes tab. Polymorphic notes service gains aggregated client view (unions client + interest + yacht notes with source chips and group-by-source toggle). Berth interest list overhaul: - Configurable columns via ColumnPicker (18 toggleable, 5 default-on) - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2) - Per-letter row tinting via colored left-border accent + dot in cell - Documents tab merged Files (single attachments section) Topbar improvements: - Always-visible back arrow on detail pages (path depth > 2) - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can push their entity hierarchy (Clients › Mary Smith › Interest › B17) - Tighter spacing, softer separators, 160px crumb truncation DataTable upgrades: - Page-size selector with All option (validator cap raised to 1000) - getRowClassName slot for per-row styling (used by berth tinting) - Fixed Radix SelectItem crash on empty-string values via __any__ sentinel (was crashing every list page that opened a select filter) Interest list: - Configurable columns picker - Stage cell clickable into detail - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons - Save view moved into ColumnPicker menu; Views button hidden when no views are saved - Pipeline kanban board endpoint at /api/v1/interests/board with minimal projection, 5000-row cap + truncated banner, filter pass-through Mobile chrome + sidebar collapse removed (always-expanded design choice). User management lists super-admins (was inner-joined on user_port_roles which excluded global super-admins). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
// `{{interest.notes}}` is now sourced from the threaded
// interest_notes timeline via EoiContext.interest.notes; this
// shallow-fallback path leaves the token blank if EoiContext
// wasn't loaded for this template render.
tokenMap['{{interest.notes}}'] = '';
}
// These are never populated by EoiContext - always fill them in.
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
tokenMap['{{interest.dateEoiSigned}}'] = interest.dateEoiSigned
feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
? formatDate(interest.dateEoiSigned, 'date.medium', { fallback: '' })
: '';
tokenMap['{{interest.dateContractSigned}}'] = interest.dateContractSigned
feat(utils): formatDate helper + sample sweep through PDF + template paths Phase 7 — single source of truth for date display. Backed by Intl.DateTimeFormat (no new dep — built into Node 18+ + every supported browser). Replaces 96 ad-hoc `new Date(x).toLocaleDateString('en-GB')` calls scattered across the codebase. src/lib/utils/format-date.ts (new): formatDate(value, preset?, options?) — primary helper formatDateRange(start, end, options?) — collapsed range strings formatRelative(value, options?) — "3 hours ago" / "in 2 days" Presets (named so callers don't memorize Intl options shape): date.short 12 May date.medium 12 May 2026 date.long Monday, 12 May 2026 date.iso 2026-05-12 (TZ-aware ISO date, no time) datetime.short 12 May 14:30 datetime.medium 12 May 2026 14:30 datetime.long Monday, 12 May 2026 at 14:30 UTC datetime.iso 2026-05-12T14:30:00.000Z time 14:30 Defensive defaults: - null/undefined/Invalid Date → '—' (overridable via { fallback }) - locale defaults to en-GB (settles audit-flagged en-US/en-GB drift) - tz passthrough to Intl.DateTimeFormat timeZone field (any IANA name) Sample sweep (3 sites — proves the pattern; remaining 93 sites can be migrated opportunistically when files are touched): src/lib/services/expense-pdf.service.ts:608 default subheader src/lib/services/document-templates.ts:364 {{interest.dateFirstContact}} src/lib/services/document-templates.ts:374-378 {{interest.date*Signed}} The 93 remaining sites are listed in docs/BACKLOG.md §G with the rule: "replace as you touch the file" — gives compounding cleanup without a single risky 90-file commit. tests/unit/format-date.test.ts (new) — 17 tests: - fallback handling (null/undefined/invalid/explicit) - date.iso correctness in UTC + non-UTC timezones - datetime.iso = full ISO string - en-GB locale-formatted output - timezone respect across NY/UTC - time-only preset - Date/string/epoch ms inputs all accepted - formatDateRange same-year collapse, different-year keep, missing ends - formatRelative: just-now / minutes / hours / days / future / invalid 1315/1315 vitest green (+17 new from format-date.test.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:34:39 +02:00
? formatDate(interest.dateContractSigned, 'date.medium', { fallback: '' })
: '';
// Derive berth number from the interest when berthId wasn't passed and
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// the EOI path didn't already populate it. Resolves through the
// interest_berths junction (plan §3.4) - the legacy interest.berth_id
// column has been removed.
const interestPrimaryBerth =
!eoiContextLoaded && !context.berthId ? await getPrimaryBerth(interest.id) : null;
if (!eoiContextLoaded && interestPrimaryBerth?.berthId && !context.berthId) {
if (interestPrimaryBerth.mooringNumber) {
tokenMap['{{interest.berthNumber}}'] = interestPrimaryBerth.mooringNumber;
if (!tokenMap['{{berth.mooringNumber}}']) {
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
tokenMap['{{berth.mooringNumber}}'] = interestPrimaryBerth.mooringNumber;
}
} else {
tokenMap['{{interest.berthNumber}}'] ??= '';
}
} else if (!eoiContextLoaded) {
tokenMap['{{interest.berthNumber}}'] ??= context.berthId
? (tokenMap['{{berth.mooringNumber}}'] ?? '')
: '';
}
}
}
// Berth tokens (legacy path - when a berthId is passed directly and EOI
// resolution didn't already populate the berth block).
if (context.berthId && !eoiContextLoaded) {
const berth = await db.query.berths.findFirst({
where: eq(berths.id, context.berthId),
});
if (berth && berth.portId === context.portId) {
tokenMap['{{berth.mooringNumber}}'] = berth.mooringNumber;
tokenMap['{{berth.area}}'] = berth.area ?? '';
tokenMap['{{berth.status}}'] = berth.status;
tokenMap['{{berth.price}}'] = berth.price ? String(berth.price) : '';
tokenMap['{{berth.priceCurrency}}'] = berth.priceCurrency;
tokenMap['{{berth.lengthFt}}'] = berth.lengthFt ? String(berth.lengthFt) : '';
tokenMap['{{berth.widthFt}}'] = berth.widthFt ? String(berth.widthFt) : '';
tokenMap['{{berth.tenureType}}'] = berth.tenureType;
tokenMap['{{berth.tenureYears}}'] = berth.tenureYears ? String(berth.tenureYears) : '';
tokenMap['{{interest.berthNumber}}'] = berth.mooringNumber;
}
}
// BR-140: Check required merge fields have values
const missing: string[] = [];
for (const [, fields] of Object.entries(MERGE_FIELDS)) {
for (const field of fields) {
if (field.required) {
const value = tokenMap[field.token];
if (value !== undefined && value.trim() === '') {
missing.push(field.label);
}
}
}
}
if (missing.length > 0) {
throw new ValidationError(`Missing required merge field values: ${missing.join(', ')}`);
}
// HTML body is required for the html template format; non-html formats
// resolve elsewhere (see template_format dispatch in PR6).
if (template.bodyHtml === null) {
throw new ValidationError('Template has no HTML body to render');
}
// Interpolate all tokens
let resolved: string = template.bodyHtml;
for (const [token, value] of Object.entries(tokenMap)) {
// Escape token for use in regex
const escaped = token.replace(/[{}]/g, '\\$&');
resolved = resolved.replace(new RegExp(escaped, 'g'), value);
}
return resolved;
}
feat(document-templates): delete TipTap-to-pdfme bridge Phase 1 / commit 12 of 14 — strips out the 571-line tiptap-to-pdfme serializer and every code path that depended on it. TipTap document templates remain as Documenso-template seed bodies; the CRM no longer renders them to PDF in-app. Deleted: src/lib/pdf/tiptap-to-pdfme.ts (571 LOC) src/lib/pdf/templates/eoi-standard-inapp.ts (337 LOC) src/app/api/v1/admin/templates/preview/route.ts src/app/api/v1/document-templates/[id]/generate/route.ts src/app/api/v1/document-templates/[id]/generate-and-send/route.ts src/lib/services/document-templates.ts:generateFromTemplate (~140 LOC) src/lib/services/document-templates.ts:generateAndSend (~40 LOC) src/lib/validators/document-templates.ts:generateAndSendSchema src/lib/validators/document-templates.ts:previewAdminTemplateSchema tests/unit/tiptap-serializer.test.ts (old bridge tests) Preserved as src/lib/pdf/tiptap-validation.ts (~70 LOC): - validateTipTapDocument() — still used to reject unsupported nodes on save in the admin template editor - TEMPLATE_VARIABLES — drives the merge-token picker in the admin template form + preview UI generateAndSign() now throws a clear ValidationError when a non-EOI template tries the in-app pathway. Use a Documenso template, or wait for the deferred AcroForm-fill admin-upload feature. seed-data.ts: "Standard EOI (in-app)" template row now seeds with stub bodyHtml + small MERGE_FIELDS array; the deleted HTML helper was never actually rendered (in-app EOI is pdf-lib AcroForm fill on the source PDF — generateEoiPdfFromTemplate, unchanged). After this commit, pdfme has zero callers left. Commit 14 drops the deps and the generate.ts shim. 1298/1298 vitest green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:11:23 +02:00
// ─── Generate from template (REMOVED) ─────────────────────────────────────────
//
// The in-app TipTap-to-PDF rendering path (`generateFromTemplate` and the
// public `generateAndSend` wrapper) was removed in the PDF stack overhaul
// (see docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md).
// Only the EOI in-app pathway survives, and it renders via pdf-lib AcroForm
// fill on the source PDF (`generateEoiFromSourcePdf` below). All other
// template types must go through Documenso.
//
// The old API routes `/api/v1/document-templates/[id]/generate` and
// `/api/v1/document-templates/[id]/generate-and-send` have been deleted.
// `generateAndSign` (the EOI signing entry point) now throws a clear
// ValidationError when a non-EOI template is requested through the in-app
// pathway.
// ─── EOI from source PDF (in-app pathway, EOI templates only) ─────────────────
/**
* BR-142: For EOI templates, the in-app pathway uses the same source PDF as
* the Documenso template - filled via pdf-lib with values from EoiContext.
* Same field names, same legal document; the only difference is who renders
* it. The form is left interactive so a recipient can adjust before signing.
*/
async function generateEoiFromSourcePdf(
template: typeof documentTemplates.$inferSelect,
portId: string,
context: GenerateInput,
meta: AuditMeta,
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
options?: { dimensionUnit?: 'ft' | 'm' },
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
): Promise<{ document: DbDocument; file: DbFile }> {
if (!context.interestId) {
throw new ValidationError('interestId is required for EOI template generation');
}
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
const eoiContext = applyOverridesToContext(
await buildEoiContext(context.interestId, portId),
applied,
);
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
});
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(
port?.slug ?? portId,
'document-templates',
template.id,
fileId,
'pdf',
);
{
const buffer = Buffer.from(pdfBytes);
const backend = await getStorageBackend();
await backend.put(storagePath, buffer, {
contentType: 'application/pdf',
sizeBytes: buffer.length,
});
}
const [fileRecord] = await db
.insert(files)
.values({
portId,
clientId: context.clientId ?? null,
filename: `${template.name.toLowerCase().replace(/\s+/g, '-')}.pdf`,
originalName: `${template.name}.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(pdfBytes.byteLength),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
const [documentRecord] = await db
.insert(documents)
.values({
portId,
clientId: context.clientId ?? null,
interestId: context.interestId,
documentType: template.templateType,
title: template.name,
status: 'draft',
fileId: fileRecord!.id,
isManualUpload: false,
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: documentRecord!.id,
newValue: {
templateId: template.id,
templateName: template.name,
source: 'eoi-source-pdf',
clientId: context.clientId,
interestId: context.interestId,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });
return { document: documentRecord!, file: fileRecord! };
}
// ─── Generate and Sign ────────────────────────────────────────────────────────
/**
* BR-142: EOI / NDA signing. Dual pathway:
* - `inapp`: produce the PDF locally (EOI templates fill the same source
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
* PDF as Documenso via pdf-lib AcroForm; other template types fall
* back to the @react-pdf/renderer path), upload to MinIO, then upload
* to Documenso and send for signing.
* - `documenso-template`: skip our PDF generation entirely; call Documenso's
* template-generate endpoint with the shared EOI context. Documenso owns
* the PDF. We still record a `documents` row for tracking.
*/
export async function generateAndSign(
templateId: string | null,
portId: string,
context: GenerateInput,
signers: GenerateAndSignInput['signers'],
pathway: 'inapp' | 'documenso-template',
meta: AuditMeta,
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
options?: { dimensionUnit?: 'ft' | 'm'; overrides?: EoiOverridesInput },
) {
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
// Phase 3b — apply per-field overrides BEFORE either pathway resolves the
// EOI context, so any setAsDefault contact promotion is visible to the
// buildEoiContext read. The returned `applied.resolved` is layered onto
// the in-memory context for useOnlyForThisEoi / fresh-value cases where
// the canonical record isn't being touched.
const applied = context.interestId
? await applyEoiOverridesBeforeRender(portId, context.interestId, options?.overrides, meta)
: { resolved: {}, documentOverrideColumns: {} };
if (pathway === 'documenso-template') {
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
return generateAndSignViaDocumensoTemplate(portId, context, meta, options, applied);
}
if (!templateId) {
throw new ValidationError('templateId is required for inapp pathway');
}
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
return generateAndSignViaInApp(templateId, portId, context, signers, meta, options, applied);
}
async function generateAndSignViaInApp(
templateId: string,
portId: string,
context: GenerateInput,
signers: GenerateAndSignInput['signers'],
meta: AuditMeta,
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
options?: { dimensionUnit?: 'ft' | 'm' },
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
) {
const template = await getTemplateById(templateId, portId);
// For EOI templates, signers default to the same set the Documenso template
// pathway uses (interest's client + hardcoded developer + approver), so the
// UI doesn't need to collect them. Non-EOI templates still require explicit
// signers since they have no canonical recipient list.
let resolvedSigners = signers;
if ((!resolvedSigners || resolvedSigners.length === 0) && template.templateType === 'eoi') {
if (!context.interestId) {
throw new ValidationError(
'interestId is required when generating an EOI without explicit signers',
);
}
const eoiCtx = await buildEoiContext(context.interestId, portId);
feat(sales): admin-configurable EOI signers + richer timeline events 1. Per-port EOI signer config - New `eoi_signers` system_settings key (JSON: { developer, approver }, each `{ name, email }`). Settings UI exposes it under Admin → Settings. - getPortEoiSigners(portId) reads the setting with a typed validator; falls back to the legacy David Mizrahi / Abbie May defaults if the row is missing or malformed (so older ports keep working until an admin saves a value). - Both EOI generation pathways now read from the helper instead of hardcoded constants: * documenso-template path (generateAndSignViaDocumensoTemplate) * in-app PDF-fill path (generateAndSignViaInApp) 2. Timeline upgrades The interest detail Activity tab now distinguishes the new automation events that arrived with sessions 1+2: - Stage auto-advances (userId='system') get a small "Auto" pill and carry their reason into the description (e.g. "Stage advanced to EOI Signed (auto-advanced — EOI signed via Documenso)"). - outcome_set events show "Marked as Won" / "Marked as Lost — went to another marina" with optional reason; trophy/X icons. - outcome_cleared events show "Reopened to {stage}" with a refresh icon. - Document events humanized: "Document 'X' fully signed" instead of "Document X: completed". - Stage labels run through stageLabel() so the timeline shows the human label, not the enum key. - Timestamps switched to relative-time with full-date tooltip. - "by system" is rendered plainly (no longer the literal user-id). tsc clean. vitest 832/832 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:19:55 +02:00
const signers = await getPortEoiSigners(portId);
resolvedSigners = [
{
name: eoiCtx.client.fullName,
email: eoiCtx.client.primaryEmail ?? '',
role: 'signer',
signingOrder: 1,
},
feat(sales): admin-configurable EOI signers + richer timeline events 1. Per-port EOI signer config - New `eoi_signers` system_settings key (JSON: { developer, approver }, each `{ name, email }`). Settings UI exposes it under Admin → Settings. - getPortEoiSigners(portId) reads the setting with a typed validator; falls back to the legacy David Mizrahi / Abbie May defaults if the row is missing or malformed (so older ports keep working until an admin saves a value). - Both EOI generation pathways now read from the helper instead of hardcoded constants: * documenso-template path (generateAndSignViaDocumensoTemplate) * in-app PDF-fill path (generateAndSignViaInApp) 2. Timeline upgrades The interest detail Activity tab now distinguishes the new automation events that arrived with sessions 1+2: - Stage auto-advances (userId='system') get a small "Auto" pill and carry their reason into the description (e.g. "Stage advanced to EOI Signed (auto-advanced — EOI signed via Documenso)"). - outcome_set events show "Marked as Won" / "Marked as Lost — went to another marina" with optional reason; trophy/X icons. - outcome_cleared events show "Reopened to {stage}" with a refresh icon. - Document events humanized: "Document 'X' fully signed" instead of "Document X: completed". - Stage labels run through stageLabel() so the timeline shows the human label, not the enum key. - Timestamps switched to relative-time with full-date tooltip. - "by system" is rendered plainly (no longer the literal user-id). tsc clean. vitest 832/832 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:19:55 +02:00
{
name: signers.developer.name,
email: signers.developer.email,
role: 'signer',
signingOrder: 2,
},
{
name: signers.approver.name,
email: signers.approver.email,
role: 'approver',
signingOrder: 3,
},
];
}
if (!resolvedSigners || resolvedSigners.length === 0) {
throw new ValidationError('signers are required for inapp pathway');
}
// EOI templates fill the same source PDF as the Documenso template (so both
feat(document-templates): delete TipTap-to-pdfme bridge Phase 1 / commit 12 of 14 — strips out the 571-line tiptap-to-pdfme serializer and every code path that depended on it. TipTap document templates remain as Documenso-template seed bodies; the CRM no longer renders them to PDF in-app. Deleted: src/lib/pdf/tiptap-to-pdfme.ts (571 LOC) src/lib/pdf/templates/eoi-standard-inapp.ts (337 LOC) src/app/api/v1/admin/templates/preview/route.ts src/app/api/v1/document-templates/[id]/generate/route.ts src/app/api/v1/document-templates/[id]/generate-and-send/route.ts src/lib/services/document-templates.ts:generateFromTemplate (~140 LOC) src/lib/services/document-templates.ts:generateAndSend (~40 LOC) src/lib/validators/document-templates.ts:generateAndSendSchema src/lib/validators/document-templates.ts:previewAdminTemplateSchema tests/unit/tiptap-serializer.test.ts (old bridge tests) Preserved as src/lib/pdf/tiptap-validation.ts (~70 LOC): - validateTipTapDocument() — still used to reject unsupported nodes on save in the admin template editor - TEMPLATE_VARIABLES — drives the merge-token picker in the admin template form + preview UI generateAndSign() now throws a clear ValidationError when a non-EOI template tries the in-app pathway. Use a Documenso template, or wait for the deferred AcroForm-fill admin-upload feature. seed-data.ts: "Standard EOI (in-app)" template row now seeds with stub bodyHtml + small MERGE_FIELDS array; the deleted HTML helper was never actually rendered (in-app EOI is pdf-lib AcroForm fill on the source PDF — generateEoiPdfFromTemplate, unchanged). After this commit, pdfme has zero callers left. Commit 14 drops the deps and the generate.ts shim. 1298/1298 vitest green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:11:23 +02:00
// pathways yield the same document). The HTML→pdfme rendering path for
// non-EOI templates was removed in the PDF stack overhaul (see the design
// spec). Send non-EOI documents via the Documenso pathway, OR — once it
// ships — the admin-uploaded AcroForm-fill template feature.
if (template.templateType !== 'eoi') {
throw new ValidationError(
`In-app PDF rendering for templates of type "${template.templateType}" is not supported. ` +
'Use a Documenso template, or upload a custom PDF (AcroForm-fill feature is deferred).',
);
}
const { document: documentRecord, file } = await generateEoiFromSourcePdf(
template,
portId,
context,
meta,
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
options,
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
applied,
feat(document-templates): delete TipTap-to-pdfme bridge Phase 1 / commit 12 of 14 — strips out the 571-line tiptap-to-pdfme serializer and every code path that depended on it. TipTap document templates remain as Documenso-template seed bodies; the CRM no longer renders them to PDF in-app. Deleted: src/lib/pdf/tiptap-to-pdfme.ts (571 LOC) src/lib/pdf/templates/eoi-standard-inapp.ts (337 LOC) src/app/api/v1/admin/templates/preview/route.ts src/app/api/v1/document-templates/[id]/generate/route.ts src/app/api/v1/document-templates/[id]/generate-and-send/route.ts src/lib/services/document-templates.ts:generateFromTemplate (~140 LOC) src/lib/services/document-templates.ts:generateAndSend (~40 LOC) src/lib/validators/document-templates.ts:generateAndSendSchema src/lib/validators/document-templates.ts:previewAdminTemplateSchema tests/unit/tiptap-serializer.test.ts (old bridge tests) Preserved as src/lib/pdf/tiptap-validation.ts (~70 LOC): - validateTipTapDocument() — still used to reject unsupported nodes on save in the admin template editor - TEMPLATE_VARIABLES — drives the merge-token picker in the admin template form + preview UI generateAndSign() now throws a clear ValidationError when a non-EOI template tries the in-app pathway. Use a Documenso template, or wait for the deferred AcroForm-fill admin-upload feature. seed-data.ts: "Standard EOI (in-app)" template row now seeds with stub bodyHtml + small MERGE_FIELDS array; the deleted HTML helper was never actually rendered (in-app EOI is pdf-lib AcroForm fill on the source PDF — generateEoiPdfFromTemplate, unchanged). After this commit, pdfme has zero callers left. Commit 14 drops the deps and the generate.ts shim. 1298/1298 vitest green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:11:23 +02:00
);
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
// Phase 3b — record per-document override columns + backfill the
// source_document_id on any client_contacts rows inserted during the
// override side-effects.
await persistDocumentOverrides(documentRecord.id, applied, meta);
// Fetch PDF bytes from the active storage backend to send to Documenso.
const pdfStream = await (await getStorageBackend()).get(file.storagePath);
const chunks: Buffer[] = [];
for await (const chunk of pdfStream) {
if (Buffer.isBuffer(chunk)) chunks.push(chunk);
else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk));
else chunks.push(Buffer.from(chunk as Uint8Array));
}
const pdfBase64 = Buffer.concat(chunks).toString('base64');
// Create Documenso document
const documensoDoc = await documensoCreate(
template.name,
pdfBase64,
resolvedSigners.map((s) => ({
name: s.name,
email: s.email,
role: s.role,
signingOrder: s.signingOrder,
})),
);
// Send document for signing
await documensoSend(documensoDoc.id);
// Update our document record with Documenso ID and status
await db
.update(documents)
.set({
documensoId: documensoDoc.id,
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
documensoNumericId: documensoDoc.numericId,
status: 'sent',
updatedAt: new Date(),
})
.where(eq(documents.id, documentRecord.id));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'document',
entityId: documentRecord.id,
newValue: { status: 'sent', documensoId: documensoDoc.id },
metadata: {
action: 'generate_and_sign',
pathway: 'inapp',
signerCount: resolvedSigners.length,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:updated', {
documentId: documentRecord.id,
changedFields: ['status', 'documensoId'],
});
return { document: { ...documentRecord, documensoId: documensoDoc.id, status: 'sent' }, file };
}
async function generateAndSignViaDocumensoTemplate(
portId: string,
context: GenerateInput,
meta: AuditMeta,
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
options?: { dimensionUnit?: 'ft' | 'm' },
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
) {
if (!context.interestId) {
throw new ValidationError('interestId is required for documenso-template pathway');
}
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
const eoiContext = applyOverridesToContext(
await buildEoiContext(context.interestId, portId),
applied,
);
feat(sales): admin-configurable EOI signers + richer timeline events 1. Per-port EOI signer config - New `eoi_signers` system_settings key (JSON: { developer, approver }, each `{ name, email }`). Settings UI exposes it under Admin → Settings. - getPortEoiSigners(portId) reads the setting with a typed validator; falls back to the legacy David Mizrahi / Abbie May defaults if the row is missing or malformed (so older ports keep working until an admin saves a value). - Both EOI generation pathways now read from the helper instead of hardcoded constants: * documenso-template path (generateAndSignViaDocumensoTemplate) * in-app PDF-fill path (generateAndSignViaInApp) 2. Timeline upgrades The interest detail Activity tab now distinguishes the new automation events that arrived with sessions 1+2: - Stage auto-advances (userId='system') get a small "Auto" pill and carry their reason into the description (e.g. "Stage advanced to EOI Signed (auto-advanced — EOI signed via Documenso)"). - outcome_set events show "Marked as Won" / "Marked as Lost — went to another marina" with optional reason; trophy/X icons. - outcome_cleared events show "Reopened to {stage}" with a refresh icon. - Document events humanized: "Document 'X' fully signed" instead of "Document X: completed". - Stage labels run through stageLabel() so the timeline shows the human label, not the enum key. - Timestamps switched to relative-time with full-date tooltip. - "by system" is rendered plainly (no longer the literal user-id). tsc clean. vitest 832/832 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:19:55 +02:00
const signers = await getPortEoiSigners(portId);
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
// Per-port Documenso template + recipient IDs (with env fallback). Each
// tenant pointing at its own Documenso instance has different numeric
// template + recipient IDs, so a global env-only setup limits the
// platform to one Documenso instance per CRM process.
const docCfg = await getPortDocumensoConfig(portId);
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
// v2 prefillFields-by-ID emission requires a field-name → field-ID map
// populated by the admin "Sync from Documenso" button. Absent (or partial)
// map → payload skips prefillFields and v2 accepts the legacy formValues
// shape via backward compat.
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
const fieldMap = await getEoiFieldMap(portId);
// Pick which side of the yacht's stored dimensions ships to Documenso.
// The drawer's toggle drives this; if the caller omitted it, default to
// whichever unit the rep originally typed in (yacht.lengthUnit). Legacy
// yachts without a unit column default to 'ft'.
const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft';
const payload = buildDocumensoPayload(
eoiContext,
{
interestId: context.interestId,
clientRecipientId: docCfg.clientRecipientId,
developerRecipientId: docCfg.developerRecipientId,
approvalRecipientId: docCfg.approvalRecipientId,
developerName: signers.developer.name,
developerEmail: signers.developer.email,
approverName: signers.approver.name,
approverEmail: signers.approver.email,
// Prefer per-port post-signing redirect (typically marketing-site
// /sign/success on v2). Falls back to APP_URL on v1 / when unset.
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
// v2-only signing-order enforcement. v1 instances ignore this key.
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
dimensionUnit,
},
fieldMap,
);
const documensoDoc = await documensoGenerateFromTemplate(
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
docCfg.eoiTemplateId,
payload as unknown as Record<string, unknown>,
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
portId,
);
// Record a documents row referencing the Documenso document. No local file -
// Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere).
const [documentRecord] = await db
.insert(documents)
.values({
portId,
clientId: context.clientId ?? null,
interestId: context.interestId,
documentType: 'eoi',
title: payload.title,
status: 'sent',
documensoId: documensoDoc.id,
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
documensoNumericId: documensoDoc.numericId,
isManualUpload: false,
createdBy: meta.userId,
})
.returning();
feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker Phase 3b — EOI dialog field overrides: - New EoiOverridesInput shape (clientEmail / clientPhone / yachtName) threaded through generate-and-sign validator + both pathways (in-app pdf-lib fill, Documenso template generate). - src/lib/services/eoi-overrides.service.ts applies side-effects in one transaction: useOnlyForThisEoi writes documents.override_* and stops; setAsDefault demotes the prior primary + promotes (existing contactId) or inserts + promotes (fresh value); neither flag inserts a non-primary client_contacts row for future dropdown reuse. - Document override columns persisted post-insert, with a 1-minute source_document_id backfill on freshly inserted contact rows. - eoi-context route returns available.{emails, phones} so the dialog can render combobox options. - <OverridableContactField> in eoi-generate-dialog.tsx renders the combobox + manual input + 2 checkboxes per field with mutually exclusive intent semantics. Phase 3c — yacht spawn from EOI dialog: - YachtForm gains createExtras + onCreated callbacks; the EOI dialog opens it as a nested Sheet pre-filled with the linked client as owner. On save the new yacht is stamped source='eoi-generated' and the interest is PATCHed with the new yachtId so the EOI context reflows. Phase 3d — promote-to-primary + audit + [EOI] badge: - POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary (transactional demote+promote via promoteContactToPrimary). - src/lib/audit.ts AuditAction type adds eoi_field_override, promote_to_primary, eoi_spawn_yacht (DB column is free-text). - ContactsEditor surfaces an [EOI] badge on non-primary rows where source='eoi-custom-input'. Phase 4 — worker + TOD picker: - processOverdueReminders refactored to UPDATE...RETURNING with a fired_at IS NULL gate so parallel workers can't double-fire. Uses the idx_reminders_due_unfired partial index from migration 0072. - /settings gets a "Default reminder time" time-of-day picker; the value lands in user_profiles.preferences.digestTimeOfDay (validated HH:MM at the route). <ReminderForm> seeds its dueAt from this preference via a React-Query me-prefs fetch. Phase 6 hardening: - IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste of Google Workspace's 16-char App Password formatted as "abcd efgh ijkl mnop" still authenticates. Workspace activation procedure documented in MASTER-PLAN §Phase 6 (was previously written to CLAUDE.md, which was bloat — moved to the plan). Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00
// Phase 3b — record any per-document override columns + backfill
// source_document_id on freshly inserted contact rows.
await persistDocumentOverrides(documentRecord!.id, applied, meta);
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
// Persist the per-recipient signer rows from Documenso's create response.
// Without these the EOI tab's "Signing progress" panel shows
// "No signers loaded" forever (the webhook handler only updates existing
// rows by token / email). Each row maps a Documenso recipient slot to
// a CRM document-signer record.
if (documensoDoc.recipients.length > 0) {
await db.insert(documentSigners).values(
documensoDoc.recipients.map((r) => {
// Strip the `(was: <email>)` suffix that `applyRecipientRedirect`
// bakes into recipient names when EMAIL_REDIRECT_TO is on. Without
// this, every downstream surface (email greeting, signing-progress
// card, document-detail page) leaks "Matt Ciaccio (was: matt@...)"
// into reps' faces. Display-only cleanup; the original email is
// still recoverable via the redirect helper.
const cleanName = (r.name || r.email)
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
// signingOrder 1 with role SIGNER is always the CLIENT in our trio
// (Client → Developer → Approver). Without this special-case the
// role gets stored as 'signer' for the client too, and the email
// template's `isClient` branch wrongly tells the client "you're
// the next signer; the client has already signed."
const role =
r.role.toUpperCase() === 'SIGNER' && r.signingOrder === 1
? 'client'
: normalizeSignerRole(r.role);
return {
documentId: documentRecord!.id,
signerName: cleanName || r.email,
signerEmail: r.email,
signerRole: role,
signingOrder: r.signingOrder,
status: 'pending' as const,
signingUrl: r.signingUrl ?? null,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
// invitedAt deliberately left null at create time. The
// send-invitation route stamps it once the branded invite goes
// out. Pre-stamping would mis-label the signer card as
// "Invited just now" in manual send mode.
invitedAt: null,
};
}),
);
}
// Stamp the interest's EOI milestone so the Overview tab flips the
// "Generate EOI" prompt to the "EOI sent / awaiting signatures" state
// immediately. Cache-invalidation on the client picks the new shape up
// via the document-templates POST's onSuccess.
await db
.update(interests)
.set({
eoiDocStatus: 'sent',
dateEoiSent: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, context.interestId));
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'document',
entityId: documentRecord!.id,
newValue: { documensoId: documensoDoc.id, status: 'sent' },
metadata: {
action: 'generate_and_sign',
pathway: 'documenso-template',
templateId: env.DOCUMENSO_TEMPLATE_ID_EOI,
interestId: context.interestId,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:created', { documentId: documentRecord!.id });
return { document: documentRecord!, file: null };
}
fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing- progress redesign + env-to-admin migration + dev-mode banner) with the 2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW). CRITICAL (3): - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths no longer silently drop interest links - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage — callers must go through /stage with the override-guard chain HIGH (14/15): - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across interests/documents/reservations/reminders/invoices (migration 0070) - H-02 login page reads ?redirect= param with same-origin guard - H-03 CRM invite token moves to URL fragment so it never lands in nginx access logs / Referer headers - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4) - H-05 toggleAccount writes an audit row - H-06 upsertSetting masks any value whose key ends with _encrypted - H-07 archiveClient cascade fires per-interest audit rows - H-08 createSalesTransporter applies SMTP_TIMEOUTS - H-09 AppShell stable children — viewport flip across breakpoint no longer destroys in-progress form drafts - H-10 portal documents page swaps Unicode glyph status icons for Lucide CheckCircle2/XCircle/Circle + aria-labels - H-12 list components swap alert(...) for toast.warning(...) - H-13 5 icon-only buttons gain aria-label - H-14 parseBody treats empty bodies as {} - H-15 admin layout renders a 403 panel instead of silent bounce - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet MEDIUM (28+): - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE WHEREs across custom-fields, notes (all 6 entity types x update + delete), client-contacts, yacht ownerClient lookup, webhook reads - M-D01 documents-hub realtime event-name typo (file:created -> uploaded) - M-EM01 portal-auth emails thread through portId - M-EM02 sendEmail accepts cc/bcc params - M-EM04 notification_digest catalog key - M-IN01 portal presigned download URLs use 4h TTL - M-IN02 OpenAI client lazy-instantiated - M-IN04 stale pdfme refs updated to pdf-lib AcroForm - M-IN05 umami.testConnection returns tagged union - M-L01 reservations tenure_type unified with berths - M-L02 report-generators canonicalize stage values - M-AU01 audit log placeholder copy fixed - M-AU04 outcome_set / outcome_cleared distinct audit verbs - M-NEW-2 activity feed entity name+type separator - M-R01 portal allowlist narrowed + portal_session backstop in proxy - M-SC02 companies archived partial index - M-SC04 audit_logs.searchText documented as DB-managed - M-S01 storage_s3_access_key_encrypted admin field - M-U01 audit log empty state uses <EmptyState> - M-U09 invoice delete dialog -> <AlertDialog> - M-U10 toast.success on ClientForm + InterestForm create/edit - M-U11 settings-form-card logo preview alt text - M-U14 mobile topbar title on clients/yachts/interests/berths - M-U15 Invoices in mobile More-sheet LOW (6/8): - L-AU01 severity defaults for security-relevant verbs - L-AU02 +13 missing actions in admin audit filter - L-AU03 +7 missing entity types in admin audit filter - L-AU04 dead listAuditLogs stubbed - L-D02 CLAUDE.md Owner-wins chain tightened Bonus — Document detail polish (#67 partial, 3/6 deliverables): - state-aware action button per signer - watcher Add UI with display-name resolution - cleanSignerName cleanup Prior session work bundled in: - Documenso v2 webhook + envelope-ID normalization + sequential signing - SigningProgress UI redesign (avatars, per-signer state, timestamps) - env->admin settings registry + RegistryDrivenForm + encrypted creds - Embedded-signing card + Test connection + setup help - Dev-mode EMAIL_REDIRECT_TO banner - Pipeline rules admin page - Sales email config card - Audit log details Sheet - EOI tab: Finalising badge, absolute timestamps, sequential indicator - Notes pipeline_stage_at_creation (migration 0069) - Documenso numeric ID dual-key webhook (migration 0068) - Dimensions criterion copy (migration 0067) Tests: 1374/1374 vitest pass. tsc clean. lint clean. See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and the user-input items still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
/**
* Documenso recipient roles arrive as ALL-CAPS strings ('SIGNER' | 'APPROVER'
* | 'CC' | 'VIEWER'); the CRM's `document_signers.signer_role` column uses
* the lowercase domain vocabulary ('client' | 'developer' | 'approver' |
* 'cc' | 'viewer' | 'other'). Map them so the UI's progress panel renders
* the right label per row. SIGNER developer is a safe default because
* the client slot is identified positionally elsewhere (signingOrder=1
* always).
*/
function normalizeSignerRole(documensoRole: string): string {
const r = documensoRole.toUpperCase();
if (r === 'APPROVER') return 'approver';
if (r === 'CC') return 'cc';
if (r === 'VIEWER') return 'viewer';
return 'signer';
}