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>
This commit is contained in:
2026-05-18 13:28:50 +02:00
parent 397dbd1490
commit 4b5f85cb7d
158 changed files with 12255 additions and 1303 deletions

View File

@@ -20,13 +20,19 @@ export function parseQuery<T extends ZodSchema>(req: NextRequest, schema: T): z.
/**
* Parses the JSON request body against a Zod schema.
* Throws a ZodError on validation failure (caught by `errorResponse`).
*
* H-14: tolerates empty request bodies (content-length 0 or req.json()
* throwing on an empty stream) by substituting `{}` so DELETE/PATCH
* routes whose schemas have all-optional fields don't crash with a
* 500 — the schema's own optionality decides whether the empty object
* is a valid input.
*/
export async function parseBody<T extends ZodSchema>(
req: NextRequest,
schema: T,
): Promise<z.infer<T>> {
const body = await req.json();
return schema.parse(body);
const body = await req.json().catch(() => ({}));
return schema.parse(body ?? {});
}
/**

View File

@@ -51,7 +51,13 @@ export type AuditAction =
// evaluateRule() call so admins can debug "why did this fire / not fire"
// without reading server logs. Distinct from the actual `update` audit
// row the auto-applied path emits when it mutates berth status.
| 'rule_evaluated';
| 'rule_evaluated'
// M-AU04: distinct verbs for outcome-set / outcome-cleared. The pre-fix
// path used a generic `update` row with `metadata.type = 'outcome_set'`,
// which the audit filter dropdown couldn't surface as its own bucket
// and the FTS GENERATED index missed entirely.
| 'outcome_set'
| 'outcome_cleared';
/**
* Common shape passed to service functions so they can stamp audit logs and
@@ -220,6 +226,24 @@ export function diffFields(
const DEFAULT_SEVERITY_BY_ACTION: Partial<Record<AuditAction, AuditSeverity>> = {
permission_denied: 'warning',
hard_delete: 'critical',
// L-AU01: explicit severities so the row badge in /admin/audit lights
// up correctly. Without these, security-relevant verbs landed as
// generic 'info' grey rows next to read events.
password_change: 'warning',
portal_invite: 'info',
portal_activate: 'info',
portal_password_reset_request: 'warning',
portal_password_reset: 'warning',
revoke_invite: 'warning',
request_gdpr_export: 'info',
send_gdpr_export: 'info',
request_hard_delete_code: 'warning',
outcome_set: 'info',
outcome_cleared: 'info',
// Webhook lifecycle defaults to warning when a delivery fails.
webhook_failed: 'warning',
webhook_dead_letter: 'error',
job_failed: 'error',
};
const AUTH_ACTIONS = new Set<AuditAction>(['login', 'logout', 'password_change']);

View File

@@ -0,0 +1,10 @@
-- The `dimensions` qualification criterion is auto-satisfied when EITHER
-- the linked yacht has length/width/draft OR the interest itself has
-- desired berth dimensions set. The original description ("We know the
-- vessel's length, width, and draft") implied the yacht-only path, which
-- confused reps after the auto-satisfy rule shipped. Updated to reflect
-- both paths.
UPDATE qualification_criteria
SET description = 'Vessel dimensions OR desired berth dimensions are recorded (length, width, draft).'
WHERE key = 'dimensions'
AND description = 'We know the vessel''s length, width, and draft.';

View File

@@ -0,0 +1,11 @@
-- Documenso v2 webhooks send only the numeric internal ID (`payload.id = 19`),
-- but the rest of the v2 API expects the public `envelope_xxx` string that we
-- already store in `documents.documenso_id`. To resolve incoming webhooks
-- against our documents, capture the numeric id alongside the envelope id at
-- create time and let the resolver try either column.
--
-- v1 documents only have a single numeric id; existing rows leave this column
-- null and continue resolving by `documenso_id` as before.
ALTER TABLE documents ADD COLUMN IF NOT EXISTS documenso_numeric_id text;
CREATE INDEX IF NOT EXISTS idx_docs_documenso_numeric_id ON documents(documenso_numeric_id);

View File

@@ -0,0 +1,10 @@
-- Snapshot the linked interest's pipeline_stage at note-creation time so
-- the timeline of notes carries the stage they were made at. Read by the
-- NotesList UI to render a per-note stage chip.
--
-- Pre-2026-05-15 rows stay null — backfill from audit_logs would be
-- inaccurate (the audit row only captures the AFTER-stage on stage moves,
-- not the at-rest state when a note was inserted). New notes carry the
-- stamp going forward.
ALTER TABLE interest_notes ADD COLUMN IF NOT EXISTS pipeline_stage_at_creation text;

View File

@@ -0,0 +1,94 @@
-- H-01: explicit ON DELETE actions for previously-implicit NO ACTION FKs.
--
-- Without an explicit action Postgres defaults to NO ACTION, so a hard-
-- delete of a parent (client, port, berth, file, document signer) is
-- blocked at FK check time — sometimes intentional, often surprising.
-- Each FK below now declares whether parent deletion is RESTRICT (block,
-- force the operator to archive the parent or unlink the children first)
-- or SET NULL (allow the deletion, null the FK so child rows stay around
-- as historical records).
--
-- All ALTER COLUMNs are idempotent because we drop the constraint first
-- (if it exists) and re-add it under the same name; if the constraint is
-- already in the desired shape this is a no-op against Postgres.
-- interests: required parent links; archive-first is the supported path,
-- so RESTRICT a hard-delete to surface the misuse loudly.
ALTER TABLE interests DROP CONSTRAINT IF EXISTS interests_port_id_ports_id_fk;
ALTER TABLE interests
ADD CONSTRAINT interests_port_id_ports_id_fk
FOREIGN KEY (port_id) REFERENCES ports(id) ON DELETE RESTRICT;
ALTER TABLE interests DROP CONSTRAINT IF EXISTS interests_client_id_clients_id_fk;
ALTER TABLE interests
ADD CONSTRAINT interests_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT;
-- documents: client/file links are nullable already; tolerate parent
-- deletion via SET NULL so the document row stays for audit purposes.
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_client_id_clients_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_file_id_files_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_file_id_files_id_fk
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE SET NULL;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_signed_file_id_files_id_fk;
ALTER TABLE documents
ADD CONSTRAINT documents_signed_file_id_files_id_fk
FOREIGN KEY (signed_file_id) REFERENCES files(id) ON DELETE SET NULL;
-- document_events: outlive their signer row so the audit trail stays
-- intact when a recipient is removed.
ALTER TABLE document_events DROP CONSTRAINT IF EXISTS document_events_signer_id_document_signers_id_fk;
ALTER TABLE document_events
ADD CONSTRAINT document_events_signer_id_document_signers_id_fk
FOREIGN KEY (signer_id) REFERENCES document_signers(id) ON DELETE SET NULL;
-- berth_reservations: every parent FK gets RESTRICT (canonical occupancy
-- record; never silently orphaned). interestId is nullable and SET NULL
-- so a reservation legitimately outlives the originating deal.
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_berth_id_berths_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_berth_id_berths_id_fk
FOREIGN KEY (berth_id) REFERENCES berths(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_port_id_ports_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_port_id_ports_id_fk
FOREIGN KEY (port_id) REFERENCES ports(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_client_id_clients_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_yacht_id_yachts_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_yacht_id_yachts_id_fk
FOREIGN KEY (yacht_id) REFERENCES yachts(id) ON DELETE RESTRICT;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_interest_id_interests_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_interest_id_interests_id_fk
FOREIGN KEY (interest_id) REFERENCES interests(id) ON DELETE SET NULL;
ALTER TABLE berth_reservations DROP CONSTRAINT IF EXISTS berth_reservations_contract_file_id_files_id_fk;
ALTER TABLE berth_reservations
ADD CONSTRAINT berth_reservations_contract_file_id_files_id_fk
FOREIGN KEY (contract_file_id) REFERENCES files(id) ON DELETE SET NULL;
-- reminders.client_id: nullable, tolerate parent delete with SET NULL.
ALTER TABLE reminders DROP CONSTRAINT IF EXISTS reminders_client_id_clients_id_fk;
ALTER TABLE reminders
ADD CONSTRAINT reminders_client_id_clients_id_fk
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL;
-- invoices.pdf_file_id: nullable, tolerate parent delete with SET NULL.
ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_pdf_file_id_files_id_fk;
ALTER TABLE invoices
ADD CONSTRAINT invoices_pdf_file_id_files_id_fk
FOREIGN KEY (pdf_file_id) REFERENCES files(id) ON DELETE SET NULL;

View File

@@ -42,6 +42,11 @@ export const companies = pgTable(
index('idx_companies_taxid')
.on(table.portId, table.taxId)
.where(sql`${table.taxId} IS NOT NULL`),
// M-SC02: partial index covering the hot "non-archived companies"
// path. Matches the pattern already used on clients/yachts/interests.
index('idx_companies_archived')
.on(table.portId)
.where(sql`${table.archivedAt} IS NULL`),
],
);

View File

@@ -69,7 +69,10 @@ export const documents = pgTable(
.notNull()
.references(() => ports.id),
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
clientId: text('client_id').references(() => clients.id),
// H-01: nullable; tolerate the owning client being hard-deleted (rare —
// archive is the normal path — but if it happens the document row
// should outlive it so the audit trail stays intact).
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
companyId: text('company_id').references(() => companies.id, { onDelete: 'set null' }),
reservationId: text('reservation_id').references(() => berthReservations.id, {
@@ -82,8 +85,16 @@ export const documents = pgTable(
title: text('title').notNull(),
status: text('status').notNull().default('draft'), // draft, sent, partially_signed, completed, expired, cancelled
documensoId: text('documenso_id'),
fileId: text('file_id').references(() => files.id),
signedFileId: text('signed_file_id').references(() => files.id),
/** Documenso v2 webhooks send only the numeric internal id (e.g. "19"),
* while every other v2 API path expects the public envelope_xxx string
* stored in `documensoId`. Captured at create-time so the webhook
* resolver can match incoming events by either id. Null for v1
* documents (where `documensoId` already holds the only id). */
documensoNumericId: text('documenso_numeric_id'),
// H-01: nullable file references; both pre- and post-signing blobs
// can be soft-archived independently of the document row.
fileId: text('file_id').references(() => files.id, { onDelete: 'set null' }),
signedFileId: text('signed_file_id').references(() => files.id, { onDelete: 'set null' }),
isManualUpload: boolean('is_manual_upload').notNull().default(false),
/** Email addresses CC'd on the completion notification (the
* passive Documenso CC concept — see plan Q4). Per-document set
@@ -119,6 +130,7 @@ export const documents = pgTable(
// the documents table fully.
index('idx_docs_file_id').on(table.fileId),
index('idx_docs_signed_file_id').on(table.signedFileId),
index('idx_docs_documenso_numeric_id').on(table.documensoNumericId),
index('idx_docs_folder').on(table.folderId),
// Composite indexes for the aggregated-projection queries
// (`listInflightWorkflowsAggregatedByEntity`) — every join carries a
@@ -173,7 +185,9 @@ export const documentEvents = pgTable(
.notNull()
.references(() => documents.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(), // created, sent, viewed, signed, completed, expired, reminder_sent
signerId: text('signer_id').references(() => documentSigners.id),
// H-01: events outlive their signer row so the audit trail stays
// intact if a recipient is removed.
signerId: text('signer_id').references(() => documentSigners.id, { onDelete: 'set null' }),
eventData: jsonb('event_data').default({}),
signatureHash: text('signature_hash'), // deduplication
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -117,7 +117,9 @@ export const invoices = pgTable(
paymentDate: date('payment_date'),
paymentMethod: text('payment_method'),
paymentReference: text('payment_reference'),
pdfFileId: text('pdf_file_id').references(() => files.id),
// H-01: nullable — losing the rendered invoice PDF shouldn't bring
// down the invoice row (totals + payments are the source of truth).
pdfFileId: text('pdf_file_id').references(() => files.id, { onDelete: 'set null' }),
/** Optional link to a sales interest. When the invoice is paid and `kind`
* is 'deposit', recordPayment auto-advances the interest's pipelineStage
* to deposit_paid (no-op if already further along). */

View File

@@ -24,12 +24,19 @@ export const interests = pgTable(
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
// H-01: deleting a port is a manual super-admin operation; interests
// shouldn't outlive their port. RESTRICT forces the operator to
// explicitly archive/transfer interests first.
portId: text('port_id')
.notNull()
.references(() => ports.id),
.references(() => ports.id, { onDelete: 'restrict' }),
// H-01: client is required and design intent is archive-first — the
// service-layer hard-delete path nullifies FKs explicitly. RESTRICT
// is a defensive backstop against an ad-hoc DB hard-delete that
// would otherwise leave the interest pointing at a missing client.
clientId: text('client_id')
.notNull()
.references(() => clients.id),
.references(() => clients.id, { onDelete: 'restrict' }),
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
/** Who owns this deal. Auto-assigned on create from system_settings
* `default_new_interest_owner`; reassignable via the interest header. */
@@ -175,6 +182,11 @@ export const interestNotes = pgTable(
content: text('content').notNull(),
mentions: text('mentions').array(), // array of mentioned user IDs
isLocked: boolean('is_locked').notNull().default(false),
/** Snapshot of the linked interest's pipeline_stage at note creation.
* Lets a rep see how the deal's notes evolved across the lifecycle
* (e.g. concerns raised at qualified vs after reservation). Backfill
* not attempted for pre-2026-05-15 rows — they stay null. */
pipelineStageAtCreation: text('pipeline_stage_at_creation'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},

View File

@@ -22,7 +22,9 @@ export const reminders = pgTable(
status: text('status').notNull().default('pending'), // pending, snoozed, completed, dismissed
assignedTo: text('assigned_to'), // user ID
createdBy: text('created_by').notNull(),
clientId: text('client_id').references(() => clients.id),
// H-01: nullable — reminder rows stay around as historical follow-up
// records even if the linked client/interest/berth is hard-deleted.
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
berthId: text('berth_id').references(() => berths.id, { onDelete: 'set null' }),
autoGenerated: boolean('auto_generated').notNull().default(false),

View File

@@ -13,24 +13,35 @@ export const berthReservations = pgTable(
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
// H-01: reservations are the canonical "who occupies a berth right
// now" record; RESTRICT on every parent FK keeps an ad-hoc DB-side
// hard-delete from leaving a reservation pointing at a missing
// berth/client/yacht. Interest is nullable + SET NULL because a
// reservation legitimately outlives the originating deal.
berthId: text('berth_id')
.notNull()
.references(() => berths.id),
.references(() => berths.id, { onDelete: 'restrict' }),
portId: text('port_id')
.notNull()
.references(() => ports.id),
.references(() => ports.id, { onDelete: 'restrict' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id),
.references(() => clients.id, { onDelete: 'restrict' }),
yachtId: text('yacht_id')
.notNull()
.references(() => yachts.id),
interestId: text('interest_id').references(() => interests.id),
.references(() => yachts.id, { onDelete: 'restrict' }),
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
status: text('status').notNull(), // 'pending' | 'active' | 'ended' | 'cancelled'
startDate: timestamp('start_date', { withTimezone: true, mode: 'date' }).notNull(),
endDate: timestamp('end_date', { withTimezone: true, mode: 'date' }),
tenureType: text('tenure_type').notNull().default('permanent'), // 'permanent' | 'fixed_term' | 'seasonal'
contractFileId: text('contract_file_id').references(() => files.id),
// M-L01: canonical tenure_type union is
// `permanent | fixed_term | fee_simple | strata_lot | seasonal`
// (kept in sync with berths.tenure_type). 'seasonal' is reservation-
// specific (winter haul-out etc.); the others mirror the berth's
// own tenure shape. Configurable via the per-port vocabulary at
// /admin/vocabularies (key: berth_tenure_types).
tenureType: text('tenure_type').notNull().default('permanent'),
contractFileId: text('contract_file_id').references(() => files.id, { onDelete: 'set null' }),
notes: text('notes'),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -48,9 +48,13 @@ export const auditLogs = pgTable(
/** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' — lets the
* UI filter by event origin without grepping action names. */
source: text('source').notNull().default('user'),
/** Full-text search column. Stored generated; updated by the migration's
* GENERATED ALWAYS expression covering action + entityType + entityId
* + actor email lookup. */
/** Full-text search column. **Read-only / DB-managed**: the column is
* declared `GENERATED ALWAYS AS (...) STORED` in migration
* 0014_black_banshee.sql (covers action + entity_type + entity_id +
* user_id). Drizzle has no first-class marker for generated columns,
* so writes through this schema property would be rejected by
* Postgres at SQL level — never set this from application code.
* M-SC04: documented to prevent accidental write attempts. */
searchText: tsvector('search_text'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},

View File

@@ -804,13 +804,14 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
}
// ── 6b. Standard EOI Template (in-app PDF path) ────────────────────────
// One row per port. Used by the in-app pdfme renderer when the port opts
// for in-app PDF generation over the Documenso template flow.
// One row per port. Used by the in-app pdf-lib AcroForm renderer when
// the port opts for in-app PDF generation over the Documenso template
// flow. (Renamed from pdfme in the 2026-05-12 PDF stack overhaul.)
await tx.insert(documentTemplates).values({
portId,
name: 'Standard EOI (in-app)',
description:
'Default Expression of Interest / Letter of Intent template, rendered in-app via pdfme. Use for ports that prefer in-app PDF generation over the Documenso template path.',
'Default Expression of Interest / Letter of Intent template, rendered in-app via pdf-lib AcroForm. Use for ports that prefer in-app PDF generation over the Documenso template path.',
templateType: 'eoi',
bodyHtml: STANDARD_EOI_BODY_HTML,
mergeFields: STANDARD_EOI_MERGE_FIELDS,

View File

@@ -17,7 +17,7 @@ import { getPortEmailConfig, type PortEmailConfig } from '@/lib/services/port-co
// worker concurrency slot for up to 2 min × 5 retry attempts = 10 min
// per job. With concurrency 5, all slots can be starved by a single
// flaky upstream. Explicit timeouts cap the worst case under a minute.
const SMTP_TIMEOUTS = {
export const SMTP_TIMEOUTS = {
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 30_000,
@@ -123,6 +123,11 @@ export async function sendEmail(
text?: string,
portId?: string,
attachments?: EmailAttachmentRef[],
// M-EM02: optional CC / BCC. Mirror the same EMAIL_REDIRECT_TO scrub
// as `to` so dev-mode redirects don't accidentally leak a CC outside
// the safety net.
cc?: string | string[],
bcc?: string | string[],
): Promise<nodemailer.SentMessageInfo> {
const cfg = portId ? await getPortEmailConfig(portId) : null;
const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter();
@@ -132,6 +137,11 @@ export async function sendEmail(
const effectiveSubject = env.EMAIL_REDIRECT_TO
? `[redirected from ${requestedTo}] ${subject}`
: subject;
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO — the redirect target
// already gets the message; CCing additional recipients would defeat
// the dev safety net.
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : cc;
const effectiveBcc = env.EMAIL_REDIRECT_TO ? undefined : bcc;
const fromHeader =
from ??
@@ -148,6 +158,8 @@ export async function sendEmail(
html,
...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}),
...(text ? { text } : {}),
...(effectiveCc ? { cc: effectiveCc } : {}),
...(effectiveBcc ? { bcc: effectiveBcc } : {}),
...(resolvedAttachments.length > 0 ? { attachments: resolvedAttachments } : {}),
});

View File

@@ -22,6 +22,12 @@ export const TEMPLATE_KEYS = [
'inquiry_sales_notification',
'residential_inquiry_client_confirmation',
'residential_inquiry_sales_alert',
// M-EM04: daily notification digest. The digest service previously
// resolved its subject via `'crm_invite' as any` because no entry
// existed; making it a first-class key removes the cast and lets
// admins override the subject from /admin/email like every other
// template.
'notification_digest',
] as const;
export type TemplateKey = (typeof TEMPLATE_KEYS)[number];
@@ -95,6 +101,14 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
defaultSubject: 'New residential inquiry — {{clientName}}',
},
notification_digest: {
key: 'notification_digest',
label: 'Notification digest',
description:
"Daily roll-up of a rep's pending notifications. Fires from the digest worker; respects per-user opt-out.",
mergeTokens: ['portName', 'recipientName', 'unreadCount'],
defaultSubject: 'Your {{portName}} CRM digest — {{unreadCount}} updates',
},
};
/** system_settings key for a template's subject override. */

View File

@@ -325,3 +325,76 @@ export async function signingReminderEmail(
text,
};
}
// ─── 4. Cancelled ─────────────────────────────────────────────────────────────
interface CancelledData {
recipientName: string;
documentLabel: string;
portName: string;
/** Optional rep-authored reason. When null, the body explains the
* cancellation without speculation; when set, the reason renders in
* the same callout style as the invitation `customMessage`. */
reason?: string | null;
}
function CancelledBody({ data, accent }: { data: CancelledData; accent: string }) {
const greeting = `Dear ${data.recipientName},`;
return (
<>
<Text style={{ marginBottom: '14px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
{data.documentLabel} cancelled
</Text>
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
The {data.documentLabel} you were signing for {data.portName} has been cancelled. No further
action is required from you any signing link previously sent is no longer valid.
</Text>
{data.reason ? (
<Text
style={{
margin: '20px 0',
fontSize: '15px',
lineHeight: '1.6',
color: '#444',
padding: '14px 18px',
background: '#f8f9fb',
borderLeft: `3px solid ${accent}`,
borderRadius: '4px',
whiteSpace: 'pre-wrap',
}}
>
{data.reason}
</Text>
) : null}
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
If you have any questions, please reach out to your representative at {data.portName}.
</Text>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '24px 0 0' }} />
<Text style={{ fontSize: '16px', marginTop: '24px' }}>
Thank you,
<br />
<strong>The {data.portName} team</strong>
</Text>
</>
);
}
export async function signingCancelledEmail(
data: CancelledData,
overrides?: RenderOpts,
): Promise<{ subject: string; html: string; text: string }> {
const accent = brandingPrimaryColor(overrides?.branding);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
: `${data.documentLabel} cancelled — ${data.portName}`;
const body = await render(<CancelledBody data={data} accent={accent} />, { pretty: false });
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} you were signing for ${data.portName} has been cancelled. No further action is required.${data.reason ? '\n\nReason: ' + data.reason : ''}\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}

View File

@@ -13,27 +13,38 @@ const envSchema = z
BETTER_AUTH_URL: z.string().url(),
CSRF_SECRET: z.string().min(32),
// MinIO
MINIO_ENDPOINT: z.string().min(1),
MINIO_PORT: z.coerce.number().int().positive(),
MINIO_ACCESS_KEY: z.string().min(1),
MINIO_SECRET_KEY: z.string().min(1),
MINIO_BUCKET: z.string().min(1),
MINIO_USE_SSL: z.enum(['true', 'false']).transform((v) => v === 'true'),
// ─── Tenant-configurable (admin UI is canonical; env is fallback) ─────
// The settings registry at src/lib/settings/registry.ts wires each of
// these into the per-port admin UI with port → global → env → default
// precedence. They're optional here so a fresh deploy without an env
// file can still boot — the operator configures everything via
// /admin/<integration> after first super-admin login. See
// docs/superpowers/specs/2026-05-15-env-to-admin-migration-design.md.
// Documenso
DOCUMENSO_API_URL: z.string().url(),
DOCUMENSO_API_KEY: z.string().min(1),
// MinIO / S3 (storage backend) — admin: /admin/storage
MINIO_ENDPOINT: z.string().min(1).optional(),
MINIO_PORT: z.coerce.number().int().positive().optional(),
MINIO_ACCESS_KEY: z.string().min(1).optional(),
MINIO_SECRET_KEY: z.string().min(1).optional(),
MINIO_BUCKET: z.string().min(1).optional(),
MINIO_USE_SSL: z
.enum(['true', 'false'])
.optional()
.transform((v) => (v == null ? undefined : v === 'true')),
// Documenso — admin: /admin/documenso
DOCUMENSO_API_URL: z.string().url().optional(),
DOCUMENSO_API_KEY: z.string().min(1).optional(),
DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'),
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8),
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192),
DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().default(193),
DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().default(194),
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16).optional(),
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().optional(),
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().optional(),
DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().optional(),
DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().optional(),
// Email
SMTP_HOST: z.string().min(1),
SMTP_PORT: z.coerce.number().int().positive(),
// Email / SMTP — admin: /admin/email
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().int().positive().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().optional(),
@@ -66,9 +77,10 @@ const envSchema = z
SENTRY_ENVIRONMENT: z.string().optional(),
SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(0.1),
// App
// App URLs — admin: /admin/general (TODO once general admin page exists;
// for now write via the API: PUT /api/v1/admin/settings/app_url)
APP_URL: z.string().url(),
PUBLIC_SITE_URL: z.string().url(),
PUBLIC_SITE_URL: z.string().url().optional(),
/**
* Client-side bundle baseline URL. Inlined at build time by Next, so
* a missing value at build leaks into the browser as the empty

View File

@@ -231,6 +231,12 @@ export const ERROR_CODES = {
status: 504,
userMessage: 'The signing service is taking too long to respond. Please try again in a moment.',
},
DOCUMENSO_V1_NOT_SUPPORTED: {
status: 400,
userMessage:
'This action requires Documenso 2.x — the connected instance is on the legacy v1 API. Ask an admin to upgrade Documenso, then retry.',
hint: 'updateEnvelope and other v2-native endpoints require the envelope API introduced in Documenso 2.0.',
},
OCR_UPSTREAM_ERROR: {
status: 502,
userMessage:

View File

@@ -67,7 +67,14 @@ export async function loadEoiTemplatePdf(): Promise<Uint8Array> {
function formatAddress(address: EoiContext['client']['address']): string {
if (!address) return '';
return [address.street, address.city, address.country].filter(Boolean).join(', ');
// EOI's Address field renders as: "street, city, REGION, postal, COUNTRY"
// with REGION as the ISO-3166-2 suffix (e.g. NY) and COUNTRY as the
// alpha-2 code (e.g. US) so the line fits in the PDF box. The separate
// `Nationality` PDF field has been retired — the resident's country code
// here is the canonical replacement.
return [address.street, address.city, address.subdivision, address.postalCode, address.countryIso]
.filter(Boolean)
.join(', ');
}
function setText(form: ReturnType<PDFDocument['getForm']>, name: string, value: string): void {
@@ -120,6 +127,7 @@ function setCheckbox(
export async function fillEoiFormFields(
pdfBytes: Uint8Array,
context: EoiContext,
options?: { dimensionUnit?: 'ft' | 'm' },
): Promise<Uint8Array> {
const doc = await PDFDocument.load(pdfBytes);
const form = doc.getForm();
@@ -128,11 +136,20 @@ export async function fillEoiFormFields(
setText(form, 'Email', context.client.primaryEmail ?? '');
setText(form, 'Address', formatAddress(context.client.address));
// Yacht + berth (EOI Section 3) are optional - leave the AcroForm fields
// blank when the interest hasn't been linked to either.
// blank when the interest hasn't been linked to either. Dimension side
// (ft|m) honours the drawer's toggle; legacy callers omit and get ft.
setText(form, 'Yacht Name', context.yacht?.name ?? '');
setText(form, 'Length', context.yacht?.lengthFt ?? '');
setText(form, 'Width', context.yacht?.widthFt ?? '');
setText(form, 'Draft', context.yacht?.draftFt ?? '');
const dimUnit: 'ft' | 'm' = options?.dimensionUnit ?? context.yacht?.lengthUnit ?? 'ft';
const yLen = dimUnit === 'ft' ? context.yacht?.lengthFt : context.yacht?.lengthM;
const yWid = dimUnit === 'ft' ? context.yacht?.widthFt : context.yacht?.widthM;
const yDra = dimUnit === 'ft' ? context.yacht?.draftFt : context.yacht?.draftM;
// Append the unit suffix so the rendered EOI reads "45 ft" / "13.7 m"
// rather than the bare number — matches the Documenso pathway.
const withDimUnit = (v: string | null | undefined): string =>
v && String(v).trim() ? `${String(v).trim()} ${dimUnit}` : '';
setText(form, 'Length', withDimUnit(yLen));
setText(form, 'Width', withDimUnit(yWid));
setText(form, 'Draft', withDimUnit(yDra));
// Berth Number = compact range for multi-berth, primary mooring for
// single-berth (formatBerthRange(['A1']) === 'A1' so single-berth is
// byte-identical to the legacy primary-only path). The dedicated
@@ -160,7 +177,10 @@ export async function fillEoiFormFields(
/**
* Convenience: loads the source PDF from disk and returns the filled bytes.
*/
export async function generateEoiPdfFromTemplate(context: EoiContext): Promise<Uint8Array> {
export async function generateEoiPdfFromTemplate(
context: EoiContext,
options?: { dimensionUnit?: 'ft' | 'm' },
): Promise<Uint8Array> {
const bytes = await loadEoiTemplatePdf();
return fillEoiFormFields(bytes, context);
return fillEoiFormFields(bytes, context, options);
}

View File

@@ -0,0 +1,35 @@
import { PDFDocument } from 'pdf-lib';
/**
* Result of inspecting a PDF's AcroForm. Used by the Documenso template
* sync flow to surface what AcroForm fields the operator's uploaded PDF
* actually has — so the admin can verify their fillable PDF matches the
* CRM's expected field-label set before any EOI is sent in anger.
*/
export interface AcroFormField {
name: string;
/**
* `getType()` from pdf-lib's PDFField subclasses — usually one of
* `PDFTextField`, `PDFCheckBox`, `PDFDropdown`, `PDFRadioGroup`,
* `PDFSignature`, `PDFButton`. Exposed verbatim so the UI can show
* the admin what each field expects at the AcroForm layer.
*/
type: string;
}
/**
* Parses the AcroForm in `pdfBytes` and returns one descriptor per form
* field. Returns an empty array when the PDF has no AcroForm at all
* (i.e. a flat / non-fillable PDF). Never throws on a missing form —
* the caller treats "empty list" as a signal to nudge the operator
* that their PDF isn't actually fillable.
*/
export async function inspectPdfAcroForm(pdfBytes: Buffer | Uint8Array): Promise<AcroFormField[]> {
const doc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
const form = doc.getForm();
const fields = form.getFields();
return fields.map((f) => ({
name: f.getName(),
type: f.constructor.name,
}));
}

View File

@@ -13,8 +13,12 @@ interface RecurringJobDef {
*/
export async function registerRecurringJobs(): Promise<void> {
const recurring: RecurringJobDef[] = [
// Documenso signature fallback poll - primary is webhooks, this is safety net
{ queue: 'documents', name: 'signature-poll', pattern: '0 */6 * * *' },
// Documenso signature fallback poll - primary is webhooks, this is the
// safety net for any missed delivery (cloudflared tunnel hiccup, transient
// 5xx on our receiver, Documenso quirk). Tightened from 6h to 5m so the
// user-facing "stuck on partially_signed" symptom only persists for the
// 5-min window between polls. Cheap query: ~1 GET per in-flight doc.
{ queue: 'documents', name: 'signature-poll', pattern: '*/5 * * * *' },
// Reminder checks
{ queue: 'notifications', name: 'reminder-check', pattern: '0 * * * *' },

View File

@@ -1,57 +1,8 @@
import { and, eq, desc, sql, gte, lte } from 'drizzle-orm';
import { db } from '@/lib/db';
import { auditLogs } from '@/lib/db/schema';
interface AuditListQuery {
page: number;
limit: number;
entityType?: string;
action?: string;
userId?: string;
entityId?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
}
export async function listAuditLogs(portId: string, query: AuditListQuery) {
const conditions = [eq(auditLogs.portId, portId)];
if (query.entityType) conditions.push(eq(auditLogs.entityType, query.entityType));
if (query.action) conditions.push(eq(auditLogs.action, query.action));
if (query.userId) conditions.push(eq(auditLogs.userId, query.userId));
if (query.entityId) conditions.push(eq(auditLogs.entityId, query.entityId));
if (query.dateFrom) conditions.push(gte(auditLogs.createdAt, new Date(query.dateFrom)));
if (query.dateTo) conditions.push(lte(auditLogs.createdAt, new Date(query.dateTo)));
if (query.search) {
conditions.push(
sql`(${auditLogs.entityType} ILIKE ${'%' + query.search + '%'} OR ${auditLogs.action} ILIKE ${'%' + query.search + '%'})`,
);
}
const offset = (query.page - 1) * query.limit;
const [data, countResult] = await Promise.all([
db
.select()
.from(auditLogs)
.where(and(...conditions))
.orderBy(desc(auditLogs.createdAt))
.limit(query.limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(auditLogs)
.where(and(...conditions)),
]);
return {
data,
pagination: {
page: query.page,
limit: query.limit,
total: Number(countResult[0]?.count ?? 0),
},
};
}
// L-AU04: the legacy ILIKE-based `listAuditLogs` was superseded by the
// FTS-backed `searchAuditLogs` (audit-search.service.ts) in 2026-05-08.
// Nothing imports the old function anymore — keeping a stub file rather
// than deleting outright in case the module path resolves elsewhere in
// build tooling. Remove the file in a follow-up sweep.
//
// All audit reads should go through `searchAuditLogs(options)` instead.
export {};

View File

@@ -617,6 +617,22 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
userAgent: meta.userAgent,
});
// H-07: emit per-interest archive rows so an auditor searching for a
// specific archived interest finds it directly — the client-level row's
// `cascadedInterestIds` array doesn't participate in audit-log FTS.
for (const interestId of archivedInterestIds) {
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'interest',
entityId: interestId,
metadata: { cascadeSource: 'client_archive', clientId: id },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
for (const interestId of archivedInterestIds) {
emitToRoom(`port:${portId}`, 'interest:archived', { interestId });
@@ -737,7 +753,8 @@ export async function updateContact(
const [updated] = await db
.update(clientContacts)
.set({ ...data, updatedAt: new Date() })
.where(eq(clientContacts.id, contactId))
// M-MT03: pin the WHERE to (id, clientId) for defense-in-depth.
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
.returning();
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
@@ -761,7 +778,10 @@ export async function removeContact(
});
if (!contact) throw new NotFoundError('Contact');
await db.delete(clientContacts).where(eq(clientContacts.id, contactId));
// M-MT03: pin (id, clientId) for defense-in-depth.
await db
.delete(clientContacts)
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)));
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
}

View File

@@ -68,7 +68,12 @@ export async function createCrmInvite(args: {
internalMessage: 'Failed to create CRM invite',
});
const link = `${env.APP_URL}/set-password?token=${raw}`;
// H-03: token moves to the URL fragment so it never lands in nginx/Caddy
// access logs (or any HTTP-Referer-leaking middlebox). The fragment is
// browser-only; the server only sees the path. set-password/page.tsx
// reads either `#token=` or `?token=` (back-compat for outstanding
// links). encodeURIComponent guards against `#`/`&` in the token.
const link = `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`;
const result = await crmInviteEmail({
link,
ttlHours: INVITE_TTL_HOURS,
@@ -230,7 +235,12 @@ export async function resendCrmInvite(
.set({ tokenHash: hash, expiresAt })
.where(eq(crmUserInvites.id, inviteId));
const link = `${env.APP_URL}/set-password?token=${raw}`;
// H-03: token moves to the URL fragment so it never lands in nginx/Caddy
// access logs (or any HTTP-Referer-leaking middlebox). The fragment is
// browser-only; the server only sees the path. set-password/page.tsx
// reads either `#token=` or `?token=` (back-compat for outstanding
// links). encodeURIComponent guards against `#`/`&` in the token.
const link = `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`;
const branding = await getBrandingShell(meta.portId);
const result = await crmInviteEmail(
{

View File

@@ -141,7 +141,13 @@ export async function updateDefinition(
...(data.isRequired !== undefined && { isRequired: data.isRequired }),
...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }),
})
.where(eq(customFieldDefinitions.id, fieldId))
// M-MT01: defense-in-depth port_id filter on the UPDATE WHERE.
// The findFirst above is the primary tenant check, but a concurrent
// port-swap (or a future cache-jitter path that lets the existing
// pointer outlive its read) would otherwise let the write land
// against a sibling port's row with the same id. The entry-point
// and the write share the same tuple identity now.
.where(and(eq(customFieldDefinitions.id, fieldId), eq(customFieldDefinitions.portId, portId)))
.returning();
const updated = updateRows[0];

View File

@@ -13,15 +13,24 @@ interface DocumensoCreds {
}
async function resolveCreds(portId?: string): Promise<DocumensoCreds> {
// env.DOCUMENSO_API_URL / env.DOCUMENSO_API_KEY are now optional — the
// canonical config lives in admin settings. Empty fallbacks let the call
// proceed; if both env + admin are blank, the downstream fetch hits an
// empty URL and errors with a clear "Documenso not configured" upstream
// (vs. crashing at type-check or boot).
if (!portId) {
return {
baseUrl: env.DOCUMENSO_API_URL,
apiKey: env.DOCUMENSO_API_KEY,
baseUrl: env.DOCUMENSO_API_URL ?? '',
apiKey: env.DOCUMENSO_API_KEY ?? '',
apiVersion: env.DOCUMENSO_API_VERSION,
};
}
const cfg = await getPortDocumensoConfig(portId);
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion };
return {
baseUrl: cfg.apiUrl ?? '',
apiKey: cfg.apiKey ?? '',
apiVersion: cfg.apiVersion,
};
}
async function documensoFetchOnce(
@@ -113,12 +122,38 @@ async function documensoFetch(
});
}
// Documenso 2.x renamed top-level `id` → `documentId` and recipient `id` →
// `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy
// `id` form that this codebase consumes everywhere downstream.
// Documenso 2.x has THREE potential ID fields on responses depending on the
// endpoint:
// - `envelopeId: string` — the public 'envelope_xxx' identifier. This is
// what every downstream endpoint expects (/envelope/update,
// /envelope/distribute, /envelope/{id}, DELETE etc).
// - `documentId: number|string` — an alias on some responses.
// - `id` — on /template/use this is the INTERNAL numeric
// pk (e.g. 17). On other endpoints `id` is sometimes the envelope_xxx
// string. On v1.13 `id` is the only field and represents the document.
//
// Resolution order: envelopeId (most reliable, v2-only) → documentId →
// id. We coerce to string everywhere downstream. A previous version of
// this normalizer used `documentId ?? id` which picked up the numeric
// internal pk from /template/use, broke envelope/update + envelope/distribute
// with "Invalid envelope ID", and silently failed every title-change +
// distribute on freshly-created envelopes.
function normalizeDocument(raw: unknown): DocumensoDocument {
const r = (raw ?? {}) as Record<string, unknown>;
const id = String(r.documentId ?? r.id ?? '');
const id = String(r.envelopeId ?? r.documentId ?? r.id ?? '');
// Documenso v2 also exposes a numeric internal pk (`id`) alongside the
// envelope_xxx string — webhooks ONLY carry the numeric id, so we
// surface it separately so the webhook resolver can match by either.
// For v1 responses `id` IS the (numeric) document id, so this is the
// same value as `id` above. For v2 with envelopeId set, this captures
// the internal pk that the webhook payload uses.
const numericIdRaw = r.id;
const numericId =
typeof numericIdRaw === 'number'
? String(numericIdRaw)
: typeof numericIdRaw === 'string' && /^\d+$/.test(numericIdRaw)
? numericIdRaw
: null;
const status = String(r.status ?? 'PENDING');
// v1.32+ payloads carry a `Recipient` (capital R) array as a legacy
// duplicate of `recipients` — fall through to it so we still resolve
@@ -142,7 +177,7 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
// see on subsequent webhook deliveries.
token: typeof rec.token === 'string' ? rec.token : undefined,
}));
return { id, status, recipients };
return { id, numericId, status, recipients };
}
export interface DocumensoRecipient {
@@ -154,6 +189,10 @@ export interface DocumensoRecipient {
export interface DocumensoDocument {
id: string;
/** Documenso v2 numeric internal pk. Populated alongside the
* envelope_xxx `id` so callers can persist both — webhooks use this
* one. Null when the response didn't include a numeric id. */
numericId: string | null;
status: string;
recipients: Array<{
id: string;
@@ -353,16 +392,179 @@ export async function generateDocumentFromTemplate(
portId?: string,
): Promise<DocumensoDocument> {
const safePayload = applyPayloadRedirect(payload);
const { apiVersion } = await resolveCreds(portId);
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{ templateId },
{ templateId, apiVersion },
'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
);
}
// v2 uses POST /api/v2/template/use with `prefillFields` keyed by field ID.
// The payload builder emits `prefillFields` when a cached field name→ID map
// exists for the port — see `buildDocumensoPayload` + `documenso-template-
// sync.service.ts`. When no map is cached we still hit /template/use but
// skip prefillFields (recipients-only); v2 instances ignore the legacy
// `formValues` field, so emit it only on v1 paths.
//
// v1 (incl. Documenso 1.13.x) uses the legacy
// /api/v1/templates/{id}/generate-document with `formValues` by name.
if (apiVersion === 'v2') {
const v2Payload = safePayload as Record<string, unknown>;
// Title PATCH must happen BEFORE distribution because Documenso v2
// restricts `envelope/update` to DRAFT envelopes only. So the v2
// flow is: 1) /template/use without distribute → DRAFT envelope, 2)
// /envelope/update with the title, 3) /envelope/distribute → PENDING
// envelope with signingUrls populated. Step 3 is REQUIRED because
// v2 doesn't return signingUrls from /template/use — without it
// `document_signers.signing_url` stays null and the manual
// "Send invitation" button errors with "Signer has no Documenso URL".
const created = await documensoFetch(
`/api/v2/template/use`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...v2Payload, templateId }),
},
portId,
).then(normalizeDocument);
const desiredTitle =
typeof v2Payload.title === 'string' && v2Payload.title.length > 0 ? v2Payload.title : null;
// `/template/use` silently drops the `meta` field on the request body —
// signingOrder, subject, message, redirectUrl all inherit from the
// template's stored defaults. To enforce the per-port `documenso_signing_
// order` (PARALLEL vs SEQUENTIAL) and per-port subject/message, replay
// the meta fields through `/envelope/update` while the envelope is still
// DRAFT (update is rejected once distributed).
const payloadMeta = (v2Payload.meta as Record<string, unknown> | undefined) ?? {};
const updateMeta: Record<string, unknown> = {};
for (const key of ['signingOrder', 'subject', 'message', 'redirectUrl', 'language'] as const) {
const value = payloadMeta[key];
if (value !== undefined && value !== null && value !== '') {
updateMeta[key] = value;
}
}
const hasMetaPatch = Object.keys(updateMeta).length > 0;
if (desiredTitle || hasMetaPatch) {
try {
const updateBody: Record<string, unknown> = { envelopeId: created.id };
if (desiredTitle) updateBody.data = { title: desiredTitle };
if (hasMetaPatch) updateBody.meta = updateMeta;
const updateResponse = await documensoFetch(
`/api/v2/envelope/update`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateBody),
},
portId,
);
// Log the raw response so we can debug when Documenso's UI keeps
// showing the template PDF filename despite our update succeeding.
// The update endpoint returns `{success: true}` on a clean ack;
// anything else hints that the title field wasn't accepted.
logger.info(
{ docId: created.id, desiredTitle, updateMeta, updateResponse },
'Documenso envelope update — response',
);
// Belt-and-braces verify: re-read the envelope and confirm the
// title persisted. Documenso v2's listing surface has been known
// to render the underlying PDF filename rather than envelope.title
// — surfacing the actual returned `title` here lets us tell
// whether the API accepted our value (and the UI is the issue)
// vs the update silently no-op'd.
try {
const verify = (await documensoFetch(
`/api/v2/envelope/${created.id}`,
{ method: 'GET' },
portId,
)) as Record<string, unknown>;
logger.info(
{
docId: created.id,
desiredTitle,
actualTitle: verify?.title,
titleMatches: verify?.title === desiredTitle,
actualMeta: verify?.documentMeta ?? verify?.envelopeMeta ?? verify?.meta,
},
'Documenso envelope update — verification',
);
} catch {
// GET verify is best-effort; don't fail generate on it.
}
} catch (err) {
logger.warn(
{ docId: created.id, updateMeta, err: err instanceof Error ? err.message : err },
'Documenso envelope update failed — created envelope keeps template default title/meta',
);
}
}
// Distribute the envelope so per-recipient signing URLs are minted.
// Without this, the recipients returned by /template/use have
// `signingUrl: null` and our "Send invitation" button errors out
// with "Signer has no Documenso URL yet."
//
// Documenso v2's distribute fires its own emails by default, but
// our payload sets `meta.distributionMethod: 'NONE'` so it just
// mints the URLs without emailing — our branded
// `sendSigningInvitation` is the dispatcher.
//
// We replace `created` with the distribute response because that's
// the call that actually returns recipients with `signingUrl`
// populated; downstream code (the document_signers insert in
// generateAndSignViaDocumensoTemplate) reads from this object.
// CRITICAL: pass `meta.distributionMethod: 'NONE'` in the distribute
// body. `/template/use` doesn't accept a `meta` field at all — our
// payload's `meta.distributionMethod: 'NONE'` is silently dropped at
// template-use time, so the envelope inherits the TEMPLATE's
// distributionMethod (which defaults to EMAIL). Without overriding
// it on the distribute call, Documenso fires its own emails the
// moment distribute runs — which clashes with our branded
// `sendSigningInvitation` flow and ignores the per-port
// `eoi_send_mode: 'manual'` setting. The override here is the
// authoritative one for v2 envelopes.
try {
const distributed = (await documensoFetch(
`/api/v2/envelope/distribute`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
envelopeId: created.id,
meta: { distributionMethod: 'NONE' },
}),
},
portId,
)) as Record<string, unknown>;
const normalized = normalizeDocument({
envelopeId: distributed.id ?? created.id,
// Distribute doesn't return the numeric id, so we synthesize it
// from the original /template/use response by passing the numeric
// id as Documenso's `id` field — normalizeDocument picks it up
// as numericId. Without this, the row would lose its numeric id
// on distribute and webhooks couldn't resolve back to it.
id: created.numericId,
status: 'PENDING',
recipients: distributed.recipients,
});
return normalized;
} catch (err) {
logger.warn(
{ docId: created.id, err: err instanceof Error ? err.message : err },
'Documenso envelope distribute failed — signingUrl will be null. Send-invitation will fail until the envelope is distributed.',
);
return created;
}
}
return documensoFetch(
`/api/v1/templates/${templateId}/generate-document`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(safePayload),
},
portId,
@@ -378,6 +580,48 @@ export async function generateDocumentFromTemplate(
* we're trying to hold comms). When the redirect is on we skip the API
* call entirely and return a synthetic "still pending" response.
*/
/**
* v2-only: distribute an envelope. Moves it from DRAFT → PENDING and
* mints per-recipient signing URLs. Does NOT email recipients when the
* envelope's `meta.distributionMethod` is `NONE` (our default — branded
* emails are dispatched by `sendSigningInvitation`).
*
* Direct call bypassing `sendDocument`'s dev-mode short-circuit. The
* self-heal path for envelopes created before the auto-distribute fix
* shipped uses this so the URLs actually get minted in dev too —
* `EMAIL_REDIRECT_TO` already rewrites recipient emails to a safe
* address at envelope-creation time, so distribute can't accidentally
* email a real client.
*/
export async function distributeEnvelopeV2(
envelopeId: string,
portId?: string,
): Promise<DocumensoDocument> {
// Architectural rule (Matt 2026-05-15): ALL outbound emails go through
// our branded `sendSigningInvitation` path — Documenso never fires its
// own emails for our envelopes. `meta.distributionMethod: 'NONE'`
// here is the ONLY place where this contract is actually enforced
// for v2 envelopes (the corresponding flag in /template/use is
// silently dropped because that endpoint doesn't accept a meta field).
const distributed = (await documensoFetch(
`/api/v2/envelope/distribute`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
envelopeId,
meta: { distributionMethod: 'NONE' },
}),
},
portId,
)) as Record<string, unknown>;
return normalizeDocument({
id: distributed.id ?? envelopeId,
status: 'PENDING',
recipients: distributed.recipients,
});
}
export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
if (env.EMAIL_REDIRECT_TO) {
logger.warn(
@@ -395,11 +639,18 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
// v2: POST /api/v2/envelope/distribute with body { envelopeId }.
// Returns the envelope with per-recipient signingUrl fields populated —
// this is one of the genuine v2 wins (saves a separate GET round-trip).
// `meta.distributionMethod: 'NONE'` is the authoritative way to suppress
// Documenso's own emails for v2 envelopes — see distributeEnvelopeV2
// for the full rationale. Branded sends are routed through
// `sendSigningInvitation` separately.
const distributed = (await documensoFetch(
'/api/v2/envelope/distribute',
{
method: 'POST',
body: JSON.stringify({ envelopeId: docId }),
body: JSON.stringify({
envelopeId: docId,
meta: { distributionMethod: 'NONE' },
}),
},
portId,
)) as Record<string, unknown>;
@@ -435,6 +686,190 @@ export async function getDocument(docId: string, portId?: string): Promise<Docum
return documensoFetch(path, undefined, portId).then(normalizeDocument);
}
// ─── Template introspection ─────────────────────────────────────────────────
export interface DocumensoTemplateRecipient {
id: number;
role: string; // 'SIGNER' | 'APPROVER' | 'CC' | 'VIEWER'
signingOrder: number;
name?: string;
email?: string;
}
export interface DocumensoTemplateField {
id: number;
type: string;
/**
* The human label assigned in the template editor — for v2 templates this
* comes from `field.fieldMeta.label`; for v1 templates it's available as
* `field.fieldMeta.label` too (the shape was preserved). Used as the key
* for the cached field-name → ID map that drives v2's `prefillFields`.
*/
label?: string;
}
export interface DocumensoTemplate {
id: number;
title: string;
recipients: DocumensoTemplateRecipient[];
fields: DocumensoTemplateField[];
/**
* v2 only. Each entry corresponds to one underlying PDF file on the
* template — usually a single envelope item per template, but Documenso
* 2.x supports stitching multiple PDFs together. Used by the sync flow
* to download each PDF and inspect its native AcroForm fields.
*/
envelopeItems: Array<{ id: string }>;
/**
* v2 only. The template's stored meta — signing order, distribution
* method, redirect URL. Surfaced in the sync report so the admin can
* confirm what the template itself is configured to do at envelope
* creation time. /template/use does NOT accept a signingOrder override,
* so these values are what every generated envelope inherits.
*/
meta: {
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
distributionMethod: 'EMAIL' | 'NONE' | null;
redirectUrl: string | null;
};
}
function normalizeTemplate(raw: unknown): DocumensoTemplate {
const r = (raw ?? {}) as Record<string, unknown>;
const id = Number(r.templateId ?? r.id ?? 0);
const title = String(r.title ?? '');
const recipientsRaw =
(r.recipients as Array<Record<string, unknown>> | undefined) ??
(r.Recipient as Array<Record<string, unknown>> | undefined) ??
[];
const recipients: DocumensoTemplateRecipient[] = recipientsRaw.map((rec) => ({
id: Number(rec.recipientId ?? rec.id ?? 0),
role: String(rec.role ?? 'SIGNER'),
signingOrder: Number(rec.signingOrder ?? 0),
name: typeof rec.name === 'string' ? rec.name : undefined,
email: typeof rec.email === 'string' ? rec.email : undefined,
}));
const fieldsRaw = (r.fields as Array<Record<string, unknown>> | undefined) ?? [];
const fields: DocumensoTemplateField[] = fieldsRaw.map((f) => {
const fieldMeta = (f.fieldMeta as Record<string, unknown> | undefined) ?? {};
return {
id: Number(f.id ?? 0),
type: String(f.type ?? ''),
label: typeof fieldMeta.label === 'string' ? fieldMeta.label : undefined,
};
});
const itemsRaw = (r.envelopeItems as Array<Record<string, unknown>> | undefined) ?? [];
const envelopeItems = itemsRaw
.map((it) => ({ id: typeof it.id === 'string' ? it.id : '' }))
.filter((it) => it.id);
// templateMeta on v2 carries the signing order + distribution method +
// post-sign redirect. v1 templates put the same data on the doc root, so
// try both shapes.
const metaRaw =
(r.templateMeta as Record<string, unknown> | undefined) ?? (r as Record<string, unknown>);
const signingOrderRaw = metaRaw.signingOrder;
const distributionRaw = metaRaw.distributionMethod;
const redirectRaw = metaRaw.redirectUrl;
const meta: DocumensoTemplate['meta'] = {
signingOrder:
signingOrderRaw === 'PARALLEL' || signingOrderRaw === 'SEQUENTIAL' ? signingOrderRaw : null,
distributionMethod:
distributionRaw === 'EMAIL' || distributionRaw === 'NONE' ? distributionRaw : null,
redirectUrl: typeof redirectRaw === 'string' && redirectRaw ? redirectRaw : null,
};
return { id, title, recipients, fields, envelopeItems, meta };
}
/**
* v2-only: download the raw PDF bytes for one envelope-item (each template
* is backed by 1+ envelope items, one per uploaded PDF). The sync flow
* uses this to inspect the PDF's AcroForm field names, surfacing whether
* the operator's fillable PDF matches the CRM's expected field-label set.
*
* v1 templates aren't supported here — the v1 download endpoint requires
* a documentId, not a templateId, and v1 doesn't expose envelope items.
*/
export async function downloadEnvelopeItemPdf(
envelopeItemId: string,
portId?: string,
version: 'signed' | 'original' = 'original',
): Promise<Buffer> {
const { baseUrl, apiKey } = await resolveCreds(portId);
const res = await fetchWithTimeout(
`${baseUrl}/api/v2/envelope/item/${envelopeItemId}/download?version=${version}`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
);
if (!res.ok) {
const errText = await res.text().catch(() => '');
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `download envelope item ${envelopeItemId}${res.status} ${errText}`,
});
}
return Buffer.from(await res.arrayBuffer());
}
/**
* Fetch a Documenso template by ID. Used by the admin "Sync from Documenso"
* flow to discover recipient slot IDs + template field IDs without forcing
* the operator to type them in by hand.
*
* - v2 path: `GET /api/v2/template/{templateId}` (returns envelope-shape JSON)
* - v1 path: `GET /api/v1/templates/{templateId}` (returns legacy doc shape;
* recipient + field arrays are present but with `id` instead of
* `recipientId`/`templateId`).
*/
export async function getTemplate(templateId: number, portId?: string): Promise<DocumensoTemplate> {
const { apiVersion } = await resolveCreds(portId);
const path =
apiVersion === 'v2' ? `/api/v2/template/${templateId}` : `/api/v1/templates/${templateId}`;
return documensoFetch(path, undefined, portId).then(normalizeTemplate);
}
/**
* v2-only: resolve a Documenso template by its envelope ID (the
* `envelope_xxxxxxxx` string that appears in the Documenso UI URL). The
* admin pastes that URL slug into the Sync input and we look up the
* matching numeric template id via `GET /api/v2/template`. Returns null
* when no template matches.
*
* Documenso 2.x's template editor URL is
* `https://.../templates/envelope_xxxxxxxx`, but the numeric `id` is not
* surfaced anywhere in the UI — so admins have no way to enter the
* numeric id by hand. This resolver bridges the gap.
*/
export async function findTemplateIdByEnvelopeId(
envelopeId: string,
portId?: string,
): Promise<number | null> {
const { apiVersion } = await resolveCreds(portId);
if (apiVersion !== 'v2') return null;
// Paginate through templates (perPage maxes at ~100 on the upstream).
// Most installs have <50 templates so the first page is usually enough.
let page = 1;
const perPage = 100;
while (page < 20) {
const res = (await documensoFetch(
`/api/v2/template?page=${page}&perPage=${perPage}`,
undefined,
portId,
)) as { data?: Array<Record<string, unknown>>; templates?: Array<Record<string, unknown>> };
const rows = res.data ?? res.templates ?? [];
if (!Array.isArray(rows) || rows.length === 0) return null;
for (const row of rows) {
const rowEnvelopeId = String(row.envelopeId ?? '');
if (rowEnvelopeId === envelopeId) {
const numericId = Number(row.id ?? 0);
return Number.isInteger(numericId) && numericId > 0 ? numericId : null;
}
}
if (rows.length < perPage) return null;
page += 1;
}
return null;
}
/**
* Email a signing reminder to one recipient. Skipped entirely when
* EMAIL_REDIRECT_TO is set - the recipient's stored email may still be
@@ -482,12 +917,26 @@ export async function sendReminder(
export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
// v2: /api/v2/envelope/{id}/download (mirrors the v1 path under the
// envelope namespace). v1: existing /documents/{id}/download.
const path =
apiVersion === 'v2'
? `/api/v2/envelope/${docId}/download`
: `/api/v1/documents/${docId}/download`;
// v2 download is a two-step lookup: there's no /envelope/{id}/download path
// (that 404s — see audit-2026-05-15). The canonical flow is:
// 1. GET /envelope/{id} → read envelopeItems[0].id
// 2. GET /envelope/item/{itemId}/download?version=signed
// v1 keeps the direct /documents/{id}/download single-call path.
if (apiVersion === 'v2') {
const envelope = (await documensoFetch(
`/api/v2/envelope/${docId}`,
{ method: 'GET' },
portId,
)) as { envelopeItems?: Array<{ id?: string }> };
const itemId = envelope.envelopeItems?.[0]?.id;
if (!itemId) {
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `v2 envelope ${docId} has no envelopeItems — cannot download signed PDF`,
});
}
return downloadEnvelopeItemPdf(itemId, portId, 'signed');
}
const path = `/api/v1/documents/${docId}/download`;
let res: Response;
try {
res = await fetchWithTimeout(`${baseUrl}${path}`, {
@@ -519,18 +968,25 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
return Buffer.from(arrayBuffer);
}
/** Convenience health-check used by the admin "Test connection" button. */
/** Convenience health-check used by the admin "Test connection" button.
*
* v2 cloud (Documenso 2.x) doesn't expose `/api/v1/health` — the old v1
* path is gone. So we probe the appropriate cheap list endpoint per
* version (`GET /api/v2/document` for v2, `GET /api/v1/health` for v1)
* and treat a 401 as "creds rejected" and a 200 as "all good". A 404
* means the URL points at something that isn't Documenso. */
export async function checkDocumensoHealth(
portId?: string,
): Promise<{ ok: boolean; status?: number; error?: string; apiVersion?: DocumensoApiVersion }> {
try {
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
// Both v1 and v2 expose /api/v1/health (v2 keeps the v1 path for
// backward compat). If a v2 deployment ever moves this we'll add a
// v2 branch — but as of Documenso 2.x there isn't a v2 health path.
const res = await fetchWithTimeout(`${baseUrl}/api/v1/health`, {
const path = apiVersion === 'v2' ? '/api/v2/document' : '/api/v1/health';
const res = await fetchWithTimeout(`${baseUrl}${path}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
// 2xx = full success; 401/403 = creds wrong but URL right (still a
// partial-success signal — the admin should know it's an auth issue,
// not a typoed URL). 404 = wrong URL.
return { ok: res.ok, status: res.status, apiVersion };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
@@ -851,3 +1307,56 @@ export async function voidDocument(docId: string, portId?: string): Promise<void
});
}
}
/**
* Update an envelope's metadata while it's still in DRAFT or PENDING — title,
* subject, message, redirect URL, signing order, language. v2-only feature
* (Documenso 1.13.x has no equivalent; admins on v1 need to void + recreate).
*
* Returns the normalized document shape so callers can persist the latest
* title locally in the `documents` row.
*/
export async function updateEnvelope(
docId: string,
patch: {
title?: string;
meta?: {
subject?: string;
message?: string;
redirectUrl?: string;
language?: string;
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
timezone?: string;
dateFormat?: string;
};
},
portId?: string,
): Promise<DocumensoDocument> {
const { apiVersion } = await resolveCreds(portId);
if (apiVersion !== 'v2') {
throw new CodedError('DOCUMENSO_V1_NOT_SUPPORTED', {
internalMessage:
'updateEnvelope requires Documenso 2.x — the v1.13.x API has no envelope/update endpoint',
});
}
// v2 update is POST /api/v2/envelope/update with the envelopeId in the
// body — NOT a PATCH against a per-id path. The body splits document
// properties (title, externalId, visibility, email) under `data` from
// email/signing settings under `meta`. Restricted to DRAFT envelopes.
const body: Record<string, unknown> = { envelopeId: docId };
if (patch.title !== undefined) {
body.data = { title: patch.title };
}
if (patch.meta) {
body.meta = patch.meta;
}
return documensoFetch(
`/api/v2/envelope/update`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
portId,
).then(normalizeDocument);
}

View File

@@ -2,7 +2,9 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { user, userProfiles } from '@/lib/db/schema/users';
import type { EoiContext } from '@/lib/services/eoi-context';
import { readSetting, SETTING_KEYS } from '@/lib/services/port-config';
export interface DocumensoTemplatePayload {
title: string;
@@ -20,6 +22,10 @@ export interface DocumensoTemplatePayload {
*/
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
};
/**
* Legacy v1 path: form-field values keyed by field NAME. Documenso v1.13.x
* accepts only this shape. v2 instances accept it via backward compat too.
*/
formValues: {
Name: string;
Email: string;
@@ -41,13 +47,37 @@ export interface DocumensoTemplatePayload {
Lease_10: boolean;
Purchase: boolean;
};
/**
* v2-native path: prefill values keyed by field ID. Generated by mapping
* each `formValues` entry through the cached `documenso_eoi_field_map`
* (name → ID) discovered via the admin's "Sync from Documenso" button.
* v1 instances ignore this field; v2 instances accept either prefillFields
* OR formValues but prefillFields-by-ID is the canonical modern path.
*/
prefillFields?: Array<{
id: number;
type: 'text' | 'number' | 'date' | 'checkbox' | 'dropdown';
value: string;
}>;
recipients: Array<{
id: number;
name: string;
email: string;
role: 'SIGNER' | 'APPROVER';
role: 'SIGNER' | 'APPROVER' | 'CC' | 'VIEWER';
signingOrder: number;
}>;
/**
* Extra recipients beyond the canonical client + developer + approver trio.
* Used by the "send a copy to my manager" workflow: pass CC slots here and
* they'll be appended to the recipients array at send time.
*/
extraRecipients?: Array<{
id: number;
name: string;
email: string;
role: 'CC' | 'VIEWER';
signingOrder?: number;
}>;
}
export interface DocumensoPayloadOptions {
@@ -69,12 +99,28 @@ export interface DocumensoPayloadOptions {
* Set via per-port `documenso_signing_order` system_settings key.
*/
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
/**
* Optional extra recipients beyond the canonical client+developer+approver
* trio. Used by the "send a copy to my manager" workflow. CC = receives a
* copy of the signed PDF; VIEWER = can view but not sign. Slot IDs must
* exist on the Documenso template (CRM operator adds them in the template
* editor first). v2-only on v2 instances; v1 ignores unknown roles.
*/
extraRecipients?: Array<{
id: number;
name: string;
email: string;
role: 'CC' | 'VIEWER';
signingOrder?: number;
}>;
/**
* Which side of the yacht's stored dimensions (ft|m) flows into the EOI's
* Length/Width/Draft formValues. Defaults to 'ft' when omitted for legacy
* call sites; the EOI-generate drawer always supplies the rep's choice.
*/
dimensionUnit?: 'ft' | 'm';
}
const DEFAULT_DEVELOPER_NAME = 'David Mizrahi';
const DEFAULT_DEVELOPER_EMAIL = 'dm@portnimara.com';
const DEFAULT_APPROVER_NAME = 'Abbie May';
const DEFAULT_APPROVER_EMAIL = 'sales@portnimara.com';
const DEFAULT_REDIRECT_URL = 'https://portnimara.com';
export interface EoiSignerConfig {
@@ -82,9 +128,9 @@ export interface EoiSignerConfig {
approver: { name: string; email: string };
}
const DEFAULT_EOI_SIGNERS: EoiSignerConfig = {
developer: { name: DEFAULT_DEVELOPER_NAME, email: DEFAULT_DEVELOPER_EMAIL },
approver: { name: DEFAULT_APPROVER_NAME, email: DEFAULT_APPROVER_EMAIL },
const EMPTY_SIGNERS: EoiSignerConfig = {
developer: { name: '', email: '' },
approver: { name: '', email: '' },
};
function isSignerEntry(v: unknown): v is { name: string; email: string } {
@@ -98,27 +144,89 @@ function isSignerEntry(v: unknown): v is { name: string; email: string } {
);
}
/** Read the per-port `eoi_signers` setting, fall back to legacy hardcoded
* defaults if missing or malformed. The fallback exists to keep older
* ports working until an admin saves the setting; once saved, the DB row
* always wins. */
/** Look up `{name, email}` for a CRM user id by joining `userProfiles`
* (display name) + `user` (auth email). Returns nulls on miss. */
async function resolveCrmUser(
userId: string | null,
): Promise<{ name: string; email: string } | null> {
if (!userId) return null;
const [row] = await db
.select({
displayName: userProfiles.displayName,
email: user.email,
})
.from(user)
.leftJoin(userProfiles, eq(userProfiles.userId, user.id))
.where(eq(user.id, userId))
.limit(1);
if (!row || !row.email) return null;
return { name: row.displayName ?? row.email, email: row.email };
}
/**
* Resolve the developer + approver name/email for the EOI signing trio.
*
* Priority chain per slot (highest → lowest):
* 1. Linked CRM user (`documenso_<role>_user_id`) — recommended path
* because "the person on this slot" changes via a CRM admin re-link,
* not a Documenso template edit. The display name comes from
* `userProfiles.displayName`, the email from `user.email`.
* 2. Free-text overrides (`documenso_<role>_name` +
* `documenso_<role>_email`) — for ports where the signer isn't a
* CRM-platform user (e.g. external counsel).
* 3. Legacy `eoi_signers` JSON blob — kept for backward compat with
* ports that haven't migrated to the registry-driven settings yet.
* 4. Empty strings — let the Documenso template's stored values win.
*
* Either slot can resolve via a different tier than the other.
*/
export async function getPortEoiSigners(portId: string): Promise<EoiSignerConfig> {
const row = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'eoi_signers'), eq(systemSettings.portId, portId)),
});
const value = row?.value as Record<string, unknown> | undefined;
if (value && isSignerEntry(value.developer) && isSignerEntry(value.approver)) {
return {
developer: value.developer,
approver: value.approver,
};
}
return DEFAULT_EOI_SIGNERS;
const [developerUserId, approverUserId, devName, devEmail, apprName, apprEmail, legacyRow] =
await Promise.all([
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId),
readSetting<string>(SETTING_KEYS.documensoApproverName, portId),
readSetting<string>(SETTING_KEYS.documensoApproverEmail, portId),
db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'eoi_signers'), eq(systemSettings.portId, portId)),
}),
]);
const legacyValue = legacyRow?.value as Record<string, unknown> | undefined;
const legacyDev =
legacyValue && isSignerEntry(legacyValue.developer) ? legacyValue.developer : null;
const legacyApr =
legacyValue && isSignerEntry(legacyValue.approver) ? legacyValue.approver : null;
const [developerFromUser, approverFromUser] = await Promise.all([
resolveCrmUser(developerUserId ?? null),
resolveCrmUser(approverUserId ?? null),
]);
const developer =
developerFromUser ??
(devName && devEmail ? { name: devName, email: devEmail } : null) ??
legacyDev ??
EMPTY_SIGNERS.developer;
const approver =
approverFromUser ??
(apprName && apprEmail ? { name: apprName, email: apprEmail } : null) ??
legacyApr ??
EMPTY_SIGNERS.approver;
return { developer, approver };
}
function formatAddress(address: EoiContext['client']['address']): string {
if (!address) return '';
return [address.street, address.city, address.country].filter(Boolean).join(', ');
// Shortest comprehensive format so the line fits the EOI's Address field:
// street, city, REGION (ISO-3166-2 suffix), postal, COUNTRY (alpha-2).
return [address.street, address.city, address.subdivision, address.postalCode, address.countryIso]
.filter(Boolean)
.join(', ');
}
function buildMessage(context: EoiContext): string {
@@ -135,9 +243,74 @@ function buildMessage(context: EoiContext): string {
export function buildDocumensoPayload(
context: EoiContext,
options: DocumensoPayloadOptions,
/**
* Cached field name → ID map from the per-port `documenso_eoi_field_map`
* setting (populated by the admin "Sync from Documenso" button). When
* provided, the payload also emits `prefillFields` keyed by ID — required
* by v2's /template/use. v1 instances ignore this field; v2 instances
* accept either prefillFields OR the legacy formValues shape.
*/
fieldMap?: Record<string, number> | null,
): DocumensoTemplatePayload {
// Honour the rep's unit choice from the EOI drawer's toggle. Defaults to
// 'ft' for legacy call sites that don't pass `dimensionUnit`; new code
// paths (generateAndSign + the drawer) always set it explicitly.
// Append the unit suffix to every dimension value so the rendered EOI
// reads "45 ft" / "13.7 m" rather than the bare number — the original
// form field doesn't tell signers which unit they're looking at.
const dimUnit: 'ft' | 'm' = options.dimensionUnit ?? 'ft';
const yachtLength = dimUnit === 'ft' ? context.yacht?.lengthFt : context.yacht?.lengthM;
const yachtWidth = dimUnit === 'ft' ? context.yacht?.widthFt : context.yacht?.widthM;
const yachtDraft = dimUnit === 'ft' ? context.yacht?.draftFt : context.yacht?.draftM;
const withUnit = (v: string | null | undefined): string =>
v && String(v).trim() ? `${String(v).trim()} ${dimUnit}` : '';
const formValues = {
Name: context.client.fullName,
Email: context.client.primaryEmail ?? '',
Address: formatAddress(context.client.address),
// Yacht + berth are optional EOI fields; when not linked, render as
// empty strings so the corresponding template inputs stay blank.
'Yacht Name': context.yacht?.name ?? '',
Length: withUnit(yachtLength),
Width: withUnit(yachtWidth),
Draft: withUnit(yachtDraft),
// formatBerthRange(['A1']) === 'A1' — so single-berth EOIs render
// identically to the legacy primary-only flow; multi-berth EOIs
// now actually show the full range instead of just the primary
// mooring.
'Berth Number': context.eoiBerthRange || (context.berth?.mooringNumber ?? ''),
Lease_10: false,
Purchase: true,
} as const;
// v2's prefillFields-by-ID emission. Map every formValue entry through the
// cached field map; skip entries that aren't in the map (template doesn't
// have that field, which is fine — Documenso silently drops unknown ones
// in v1 too).
const prefillFields = fieldMap
? Object.entries(formValues)
.map(([label, value]) => {
const fieldId = fieldMap[label];
if (fieldId == null) return null;
const isBoolean = typeof value === 'boolean';
return {
id: fieldId,
type: isBoolean ? ('checkbox' as const) : ('text' as const),
value: String(value),
};
})
.filter((x): x is { id: number; type: 'text' | 'checkbox'; value: string } => x !== null)
: undefined;
// Title format: "<full name>-EOI-NDA[-<berth range>]". When the EOI is
// tied to one or more berths, append the formatted range so the doc
// identifies the deal at a glance in lists and Documenso dashboards.
const berthSuffix = context.eoiBerthRange || context.berth?.mooringNumber || '';
return {
title: `${context.client.fullName}-EOI-NDA`,
title: berthSuffix
? `${context.client.fullName}-EOI-NDA-${berthSuffix}`
: `${context.client.fullName}-EOI-NDA`,
externalId: `loi-${options.interestId}`,
meta: {
message: buildMessage(context),
@@ -146,24 +319,14 @@ export function buildDocumensoPayload(
distributionMethod: 'NONE',
...(options.signingOrder ? { signingOrder: options.signingOrder } : {}),
},
formValues: {
Name: context.client.fullName,
Email: context.client.primaryEmail ?? '',
Address: formatAddress(context.client.address),
// Yacht + berth are optional EOI fields; when not linked, render as
// empty strings so the corresponding template inputs stay blank.
'Yacht Name': context.yacht?.name ?? '',
Length: context.yacht?.lengthFt ?? '',
Width: context.yacht?.widthFt ?? '',
Draft: context.yacht?.draftFt ?? '',
// formatBerthRange(['A1']) === 'A1' — so single-berth EOIs render
// identically to the legacy primary-only flow; multi-berth EOIs
// now actually show the full range instead of just the primary
// mooring.
'Berth Number': context.eoiBerthRange || (context.berth?.mooringNumber ?? ''),
Lease_10: false,
Purchase: true,
},
formValues,
...(prefillFields && prefillFields.length > 0 ? { prefillFields } : {}),
// Per Documenso v2's /template/use schema, `email` and `name` accept "" as
// a sentinel meaning "use the value baked into the template recipient".
// So when an admin leaves the developer/approver name/email blank in our
// admin settings, we pass "" rather than a hardcoded fallback — Documenso
// then takes the email/name set on the template itself. A non-empty
// admin value still wins (overrides the template at send time).
recipients: [
{
id: options.clientRecipientId,
@@ -174,18 +337,29 @@ export function buildDocumensoPayload(
},
{
id: options.developerRecipientId,
name: options.developerName ?? DEFAULT_DEVELOPER_NAME,
email: options.developerEmail ?? DEFAULT_DEVELOPER_EMAIL,
name: options.developerName ?? '',
email: options.developerEmail ?? '',
role: 'SIGNER',
signingOrder: 2,
},
{
id: options.approvalRecipientId,
name: options.approverName ?? DEFAULT_APPROVER_NAME,
email: options.approverEmail ?? DEFAULT_APPROVER_EMAIL,
name: options.approverName ?? '',
email: options.approverEmail ?? '',
role: 'APPROVER',
signingOrder: 3,
},
// Append CC / VIEWER slots after the canonical trio so their signing
// order doesn't collide with 1/2/3. Documenso doesn't require
// signingOrder uniqueness across non-signing roles but we still hand
// out monotonic numbers (4, 5, …) for predictability.
...(options.extraRecipients ?? []).map((extra, idx) => ({
id: extra.id,
name: extra.name,
email: extra.email,
role: extra.role,
signingOrder: extra.signingOrder ?? 4 + idx,
})),
],
};
}

View File

@@ -0,0 +1,307 @@
import {
downloadEnvelopeItemPdf,
getTemplate,
type DocumensoTemplate,
} from '@/lib/services/documenso-client';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { writeSetting } from '@/lib/settings/resolver';
import { getSetting as getSettingLegacy } from '@/lib/services/settings.service';
import { inspectPdfAcroForm, type AcroFormField } from '@/lib/pdf/inspect-acroform';
import { logger } from '@/lib/logger';
import type { AuditMeta } from '@/lib/audit';
export interface TemplateFieldMap {
[label: string]: number;
}
/**
* Field labels the CRM expects to fill on every EOI. Kept in sync with the
* keys of `formValues` inside `buildDocumensoPayload`. When a template's
* field labels diverge from this set, the unmatched entries are surfaced in
* the sync result so the admin can rename in the Documenso template editor.
*/
const CRM_EXPECTED_EOI_FIELD_LABELS = [
'Name',
'Email',
'Address',
'Yacht Name',
'Length',
'Width',
'Draft',
'Berth Number',
'Lease_10',
'Purchase',
] as const;
export interface TemplateSyncResult {
/** ISO timestamp of when this sync ran. Surfaced as "Last synced X ago"
* so the admin can tell when the cached report is from. */
syncedAt: string;
templateId: number;
title: string;
/** Pre-filled into the matching documenso_*_recipient_id settings. */
recipients: Array<{
role: string;
signingOrder: number;
id: number;
name?: string;
email?: string;
/** Which CRM setting key this row was written to (null = no match). */
mappedSettingKey: string | null;
}>;
/** Stored at `documenso_eoi_field_map` for v2 prefillFields-by-ID. */
fieldMap: TemplateFieldMap;
fieldCount: number;
/**
* Template fields the CRM knows how to fill at send time. The admin doesn't
* need to do anything for these — they'll flow through prefillFields on
* the next EOI send.
*/
matchedFields: Array<{ label: string; fieldId: number }>;
/**
* Template fields discovered on Documenso side but with labels the CRM
* doesn't recognize. These will NOT be filled. The admin should rename
* them in the Documenso template editor to match a CRM expected label.
*/
unmatchedTemplateFields: Array<{ label: string; fieldId: number }>;
/**
* CRM-expected fields that don't exist on the template. The CRM will skip
* them at send time. May be benign (e.g. you removed a field from the
* template intentionally) or a typo (rename a template field to match).
*/
missingFromTemplate: string[];
/**
* The template's stored meta — what every envelope generated from this
* template inherits at creation time. signingOrder is bound to the
* template (v2's /template/use does NOT accept an override), so this
* is the authoritative value the admin sees.
*/
templateMeta: {
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
distributionMethod: 'EMAIL' | 'NONE' | null;
redirectUrl: string | null;
};
/**
* v2 only. Each entry is one underlying PDF file behind the template,
* with its AcroForm field roster and a diff against the CRM's expected
* EOI field labels. The admin uses this to verify their fillable PDF
* actually has the named fields the CRM will fill via `formValues`.
*
* Empty array on v1 or when the PDF download / parse fails — diagnostic
* messages go to pino so the admin still sees the rest of the sync result.
*/
acroForm: Array<{
envelopeItemId: string;
fields: AcroFormField[];
/** Names from the PDF that match a CRM-expected EOI label. */
matchedFieldNames: string[];
/**
* CRM-expected labels missing from the PDF's AcroForm. These won't
* be filled at send time on the AcroForm path. (Independent of the
* Documenso-overlay-field diff above.)
*/
missingFieldNames: string[];
/**
* Names in the PDF AcroForm the CRM has no token for. Usually
* harmless leftover fields from the original PDF authoring tool
* (signature blocks, etc.).
*/
extraFieldNames: string[];
/** Set when download or parse failed — string surfaces in the UI. */
error?: string;
}>;
}
/**
* Map a template recipient to its CRM **recipient-id** setting key by role +
* signing order. These are the Documenso-internal numeric IDs that bind a
* /template/use call's recipients block to the template's slots.
*
* Distinct from `documenso_developer_user_id` / `documenso_approver_user_id`,
* which are CRM user UUIDs for in-CRM notification routing (RBAC binding).
*
* Convention (matches buildDocumensoPayload's signing order):
* signingOrder 1, role=SIGNER → client recipient slot
* signingOrder 2, role=SIGNER → developer recipient slot
* signingOrder 3, role=APPROVER → approver recipient slot
*
* Returns null if the role/order combination doesn't match any known slot —
* useful future-proofing for templates that add CC / VIEWER recipients.
*/
function mapRecipientToSettingKey(role: string, signingOrder: number): string | null {
const r = role.toUpperCase();
if (r === 'SIGNER' && signingOrder === 1) return 'documenso_client_recipient_id';
if (r === 'SIGNER' && signingOrder === 2) return 'documenso_developer_recipient_id';
if (r === 'APPROVER' && signingOrder === 3) return 'documenso_approval_recipient_id';
return null;
}
/**
* Fetch the Documenso template, write the discovered recipient IDs into the
* matching CRM settings, and cache the field name→ID map for v2 prefillFields.
*
* Throws when the template fetch fails (bad credentials, wrong template ID,
* network) — caller surfaces the error to the admin via the form's mutation
* onError + toastError.
*/
export async function syncDocumensoTemplate(
templateId: number,
portId: string,
meta: AuditMeta,
): Promise<TemplateSyncResult> {
const template: DocumensoTemplate = await getTemplate(templateId, portId);
// Build the recipient-slot map and persist to system_settings.
const recipientRows: TemplateSyncResult['recipients'] = [];
for (const rec of template.recipients) {
const settingKey = mapRecipientToSettingKey(rec.role, rec.signingOrder);
recipientRows.push({
role: rec.role,
signingOrder: rec.signingOrder,
id: rec.id,
name: rec.name,
email: rec.email,
mappedSettingKey: settingKey,
});
if (settingKey) {
await writeSetting(settingKey, rec.id, portId, meta);
}
}
// Build the field-label → field-id map. Drives v2's prefillFields. The
// field-map is itself a registry-unaware setting (the registry only knows
// about scalar credentials/URLs, not arbitrary JSON blobs), so write
// through the legacy upsertSetting path to avoid registry validation.
const fieldMap: TemplateFieldMap = {};
for (const f of template.fields) {
if (f.label) fieldMap[f.label] = f.id;
}
await persistFieldMap(portId, fieldMap, meta);
// Persist the canonical template ID so a subsequent send doesn't depend on
// the admin form remembering it after the sync click.
await writeSetting('documenso_eoi_template_id', templateId, portId, meta);
// Diff the discovered field labels against what the CRM expects to fill.
// Drives the matched / unmatched / missing lists the admin sees post-sync.
const expected = new Set<string>(CRM_EXPECTED_EOI_FIELD_LABELS);
const discovered = new Set<string>(Object.keys(fieldMap));
const matchedFields: TemplateSyncResult['matchedFields'] = [];
const unmatchedTemplateFields: TemplateSyncResult['unmatchedTemplateFields'] = [];
for (const [label, fieldId] of Object.entries(fieldMap)) {
if (expected.has(label)) matchedFields.push({ label, fieldId });
else unmatchedTemplateFields.push({ label, fieldId });
}
const missingFromTemplate: string[] = CRM_EXPECTED_EOI_FIELD_LABELS.filter(
(lbl) => !discovered.has(lbl),
);
// v2 only: download each underlying PDF and inspect its AcroForm. v1
// doesn't expose envelope items so the array stays empty there.
const acroForm: TemplateSyncResult['acroForm'] = [];
const cfg = await getPortDocumensoConfig(portId);
if (cfg.apiVersion === 'v2' && template.envelopeItems.length > 0) {
for (const item of template.envelopeItems) {
try {
const pdfBytes = await downloadEnvelopeItemPdf(item.id, portId);
const fields = await inspectPdfAcroForm(pdfBytes);
const pdfNames = new Set(fields.map((f) => f.name));
const matchedFieldNames = CRM_EXPECTED_EOI_FIELD_LABELS.filter((lbl) => pdfNames.has(lbl));
const missingFieldNames = CRM_EXPECTED_EOI_FIELD_LABELS.filter((lbl) => !pdfNames.has(lbl));
const extraFieldNames = fields.map((f) => f.name).filter((n) => !expected.has(n));
acroForm.push({
envelopeItemId: item.id,
fields,
matchedFieldNames,
missingFieldNames,
extraFieldNames,
});
} catch (err) {
// Surface the failure in the UI rather than failing the whole
// sync — the recipient + field-map writes already succeeded
// and an admin can still progress without the AcroForm diff.
logger.warn(
{ err, envelopeItemId: item.id, templateId },
'AcroForm inspection failed during template sync',
);
acroForm.push({
envelopeItemId: item.id,
fields: [],
matchedFieldNames: [],
missingFieldNames: [...CRM_EXPECTED_EOI_FIELD_LABELS],
extraFieldNames: [],
error: err instanceof Error ? err.message : 'PDF inspection failed',
});
}
}
}
const result: TemplateSyncResult = {
syncedAt: new Date().toISOString(),
templateId: template.id || templateId,
title: template.title,
recipients: recipientRows,
fieldMap,
fieldCount: Object.keys(fieldMap).length,
matchedFields,
unmatchedTemplateFields,
missingFromTemplate,
templateMeta: template.meta,
acroForm,
};
// Cache the full report so the admin's status panel survives page reloads.
// Free-form JSON, written via the legacy upsertSetting path (the registry
// resolver only handles scalar credentials/URLs). Last sync wins.
const { upsertSetting } = await import('@/lib/services/settings.service');
await upsertSetting('documenso_eoi_template_sync_report', result, portId, meta);
return result;
}
/**
* Read the cached sync report written on the most recent successful sync.
* Drives the post-reload status panel — returns null when no sync has
* ever run for this port.
*/
export async function getEoiTemplateSyncReport(portId: string): Promise<TemplateSyncResult | null> {
const row = await getSettingLegacy('documenso_eoi_template_sync_report', portId);
const stored = row?.value as unknown;
if (!stored || typeof stored !== 'object') return null;
return stored as TemplateSyncResult;
}
/**
* Read the cached field-name → field-id map for this port. Used by
* `buildDocumensoPayload` when emitting v2's `prefillFields` array.
* Returns null when no sync has run yet — caller falls back to v1's
* `formValues`-by-name shape.
*/
export async function getEoiFieldMap(portId: string): Promise<TemplateFieldMap | null> {
// The field map is a free-form JSON blob that doesn't fit the registry's
// scalar-credential model — read directly via the legacy settings.service
// path so the registry-aware resolver doesn't reject the unknown key.
const row = await getSettingLegacy('documenso_eoi_field_map', portId);
const stored = row?.value;
if (!stored || typeof stored !== 'object') return null;
const out: TemplateFieldMap = {};
for (const [k, v] of Object.entries(stored as Record<string, unknown>)) {
if (typeof v === 'number') out[k] = v;
else if (typeof v === 'string' && /^\d+$/.test(v)) out[k] = Number(v);
}
return Object.keys(out).length > 0 ? out : null;
}
/**
* Write the field map via the legacy settings.service path so we don't have
* to add an arbitrary-JSON entry to the registry. The map is per-port and
* gets blown away + rewritten on every sync click.
*/
async function persistFieldMap(
portId: string,
fieldMap: TemplateFieldMap,
meta: AuditMeta,
): Promise<void> {
const { upsertSetting } = await import('@/lib/services/settings.service');
await upsertSetting('documenso_eoi_field_map', fieldMap, portId, meta);
}

View File

@@ -33,6 +33,7 @@ import pLimit from 'p-limit';
import { sendEmail } from '@/lib/email';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import {
signingCancelledEmail,
signingCompletedEmail,
signingInvitationEmail,
signingReminderEmail,
@@ -71,6 +72,19 @@ export interface SigningReminderArgs extends Omit<SigningInvitationArgs, 'signer
invitedAgo: string;
}
export interface SigningCancelledArgs {
portId: string;
portName: string;
/** Recipients to notify of the cancellation. Caller decides who —
* the rep typically picks a subset of the original signers via the
* cancel-with-notify modal. Empty list = no emails fire (the
* Regenerate flow path). */
recipients: Array<{ name: string; email: string }>;
documentLabel: DocumentLabel;
/** Optional rep-authored explanation rendered as a callout. */
reason?: string | null;
}
export interface SigningCompletedArgs {
portId: string;
portName: string;
@@ -277,3 +291,41 @@ export async function sendSigningCompleted(args: SigningCompletedArgs): Promise<
),
);
}
/**
* Notify a subset of signers that an EOI / contract has been cancelled.
* Called by the cancel-with-notify modal — empty `recipients` is a
* no-op (the Regenerate path, where the rep wants to silently void).
*/
export async function sendSigningCancelled(args: SigningCancelledArgs): Promise<void> {
if (args.recipients.length === 0) return;
const branding = await getBrandingShell(args.portId);
const sendLimit = pLimit(3);
await Promise.all(
args.recipients.map((recipient) =>
sendLimit(async () => {
const { subject, html, text } = await signingCancelledEmail(
{
recipientName: recipient.name,
documentLabel: args.documentLabel,
portName: args.portName,
reason: args.reason ?? null,
},
{ branding },
);
try {
await sendEmail(recipient.email, subject, html, undefined, text, args.portId);
logger.info(
{ portId: args.portId, recipient: recipient.email, documentLabel: args.documentLabel },
'Signing-cancelled email sent',
);
} catch (err) {
logger.error(
{ err, portId: args.portId, recipient: recipient.email },
'Signing-cancelled email send failed',
);
}
}),
),
);
}

View File

@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { formatDate } from '@/lib/utils/format-date';
import { documentTemplates, documents, files } from '@/lib/db/schema/documents';
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';
@@ -483,13 +483,16 @@ async function generateEoiFromSourcePdf(
portId: string,
context: GenerateInput,
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' },
): Promise<{ document: DbDocument; file: DbFile }> {
if (!context.interestId) {
throw new ValidationError('interestId is required for EOI template generation');
}
const eoiContext = await buildEoiContext(context.interestId, portId);
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext);
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
});
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
@@ -569,9 +572,9 @@ async function generateEoiFromSourcePdf(
/**
* BR-142: EOI / NDA signing. Dual pathway:
* - `inapp`: produce the PDF locally (EOI templates fill the same source
* PDF as Documenso via pdf-lib; other template types fall back to the
* HTML→pdfme path), upload to MinIO, then upload to Documenso and send
* for signing.
* 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.
@@ -583,14 +586,15 @@ export async function generateAndSign(
signers: GenerateAndSignInput['signers'],
pathway: 'inapp' | 'documenso-template',
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' },
) {
if (pathway === 'documenso-template') {
return generateAndSignViaDocumensoTemplate(portId, context, meta);
return generateAndSignViaDocumensoTemplate(portId, context, meta, options);
}
if (!templateId) {
throw new ValidationError('templateId is required for inapp pathway');
}
return generateAndSignViaInApp(templateId, portId, context, signers, meta);
return generateAndSignViaInApp(templateId, portId, context, signers, meta, options);
}
async function generateAndSignViaInApp(
@@ -599,6 +603,7 @@ async function generateAndSignViaInApp(
context: GenerateInput,
signers: GenerateAndSignInput['signers'],
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' },
) {
const template = await getTemplateById(templateId, portId);
@@ -656,6 +661,7 @@ async function generateAndSignViaInApp(
portId,
context,
meta,
options,
);
// Fetch PDF bytes from the active storage backend to send to Documenso.
@@ -688,6 +694,7 @@ async function generateAndSignViaInApp(
.update(documents)
.set({
documensoId: documensoDoc.id,
documensoNumericId: documensoDoc.numericId,
status: 'sent',
updatedAt: new Date(),
})
@@ -721,6 +728,7 @@ async function generateAndSignViaDocumensoTemplate(
portId: string,
context: GenerateInput,
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' },
) {
if (!context.interestId) {
throw new ValidationError('interestId is required for documenso-template pathway');
@@ -734,21 +742,39 @@ async function generateAndSignViaDocumensoTemplate(
// platform to one Documenso instance per CRM process.
const docCfg = await getPortDocumensoConfig(portId);
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 } : {}),
});
// 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(
docCfg.eoiTemplateId,
@@ -768,11 +794,73 @@ async function generateAndSignViaDocumensoTemplate(
title: payload.title,
status: 'sent',
documensoId: documensoDoc.id,
documensoNumericId: documensoDoc.numericId,
isManualUpload: false,
createdBy: meta.userId,
})
.returning();
// 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,
@@ -794,3 +882,20 @@ async function generateAndSignViaDocumensoTemplate(
return { document: documentRecord!, file: null };
}
/**
* 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';
}

View File

@@ -1,4 +1,4 @@
import { and, desc, eq, gte, inArray, isNull, lt, lte, ne, sql, exists } from 'drizzle-orm';
import { and, desc, eq, gte, inArray, isNull, lt, lte, ne, or, sql, exists } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
@@ -26,7 +26,7 @@ import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { evaluateRule } from '@/lib/services/berth-rules-engine';
import { PIPELINE_STAGES } from '@/lib/constants';
import { advanceStageIfBehind } from '@/lib/services/interests.service';
import { advanceStageIfBehind, advanceStageIfBehindGated } from '@/lib/services/interests.service';
import {
createDocument as documensoCreate,
sendDocument as documensoSend,
@@ -184,7 +184,14 @@ export async function listDocuments(
if (interestId) filters.push(eq(documents.interestId, interestId));
if (clientId) filters.push(eq(documents.clientId, clientId));
if (documentType) filters.push(eq(documents.documentType, documentType));
if (status) filters.push(eq(documents.status, status));
if (status) {
filters.push(eq(documents.status, status));
} else {
// Hide soft-deleted rows by default from the standard list endpoint.
// Callers that explicitly want the deleted bucket pass `status='deleted'`
// (e.g. the "Deleted" filter chip on the EOI history list).
filters.push(ne(documents.status, 'deleted'));
}
if (query.folderId !== undefined) {
if (query.folderId === null) {
filters.push(isNull(documents.folderId));
@@ -603,14 +610,58 @@ export async function updateDocument(
// ─── Delete ───────────────────────────────────────────────────────────────────
/**
* Soft-deletes a document by setting `status='deleted'`. The row stays so
* audit-log/event history references it; the document is hidden from
* primary lists via the `status != 'deleted'` filter at the read layer.
*
* If the document is wired to a Documenso envelope, the call also voids
* the envelope upstream (Documenso DELETE = void, not hard-erase; the
* envelope moves to VOIDED status in Documenso's UI so it stops
* accepting signatures and outstanding signing URLs invalidate).
*
* Refuses to delete a document in the middle of signing (`sent` /
* `partially_signed`) — reps must cancel first, then delete the
* cancelled record.
*/
export async function deleteDocument(id: string, portId: string, meta: AuditMeta) {
const existing = await getDocumentById(id, portId);
if (['sent', 'partially_signed'].includes(existing.status)) {
throw new ConflictError('Cannot delete a document that is currently in signing process');
throw new ConflictError(
'Cannot delete a document while signing is in progress — cancel it first, then delete the cancelled record.',
);
}
if (existing.status === 'deleted') {
// Idempotent: a second DELETE is a no-op rather than a 409.
return;
}
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.portId, portId)));
// Best-effort upstream void. A transient Documenso failure shouldn't
// block the CRM-side delete — the document_events row + audit log
// capture what happened, and `voidDocument` treats 404 (already gone)
// as success so a Documenso UI re-delete remains safe.
if (existing.documensoId) {
try {
await documensoVoid(existing.documensoId, portId);
} catch (err) {
logger.warn(
{ err, documentId: id, documensoId: existing.documensoId },
'Documenso void failed during delete; soft-deleting CRM-side anyway',
);
}
}
await db
.update(documents)
.set({ status: 'deleted', updatedAt: new Date() })
.where(and(eq(documents.id, id), eq(documents.portId, portId)));
await db.insert(documentEvents).values({
documentId: id,
eventType: 'deleted',
eventData: { initiatedBy: meta.userId },
});
void createAuditLog({
userId: meta.userId,
@@ -619,6 +670,7 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
entityType: 'document',
entityId: id,
oldValue: { title: existing.title, status: existing.status },
newValue: { status: 'deleted' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -788,7 +840,14 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
// Advance pipeline stage to eoi (no-op if already further along).
// Doc sub-status is set by the webhook receiver when Documenso confirms;
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
void advanceStageIfBehind(interest.id, portId, 'eoi', meta, 'EOI sent for signing');
void advanceStageIfBehindGated(
interest.id,
portId,
'eoi',
meta,
'EOI sent for signing',
'eoi_sent',
);
await db
.update(interests)
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
@@ -998,9 +1057,19 @@ async function resolveWebhookDocument(
documensoId: string,
portId: string | undefined,
): Promise<typeof documents.$inferSelect | null> {
// Documenso v1 sends `payload.id` = the same numeric id we stored in
// `documents.documenso_id`. Documenso v2 sends `payload.id` = the
// internal numeric pk, while `documents.documenso_id` holds the public
// `envelope_xxx` string and the numeric pk lives in
// `documents.documenso_numeric_id`. Match either column so both
// versions resolve.
const idMatch = or(
eq(documents.documensoId, documensoId),
eq(documents.documensoNumericId, documensoId),
);
if (portId) {
const doc = await db.query.documents.findFirst({
where: and(eq(documents.documensoId, documensoId), eq(documents.portId, portId)),
where: and(idMatch, eq(documents.portId, portId)),
});
if (!doc) {
logger.warn({ documensoId, portId }, 'Document not found for webhook (port-scoped)');
@@ -1009,7 +1078,7 @@ async function resolveWebhookDocument(
return doc;
}
const matches = await db.query.documents.findMany({
where: eq(documents.documensoId, documensoId),
where: idMatch,
});
if (matches.length === 0) {
logger.warn({ documensoId }, 'Document not found for webhook');
@@ -1052,9 +1121,24 @@ export async function handleRecipientSigned(eventData: {
eq(documentSigners.signerEmail, eventData.recipientEmail),
);
// Read prior status so we know whether this delivery is the first
// signing transition. Documenso v2 retries deliver the same
// DOCUMENT_RECIPIENT_COMPLETED multiple times with slightly different
// rawBody hashes — without this gate the cascade fires on every
// delivery, the "your turn" email goes out twice, and downstream side
// effects (rule engine, audit, notifications) duplicate.
const [priorSigner] = await db.select().from(documentSigners).where(signerWhere);
const wasAlreadySigned = priorSigner?.status === 'signed';
const [signer] = await db
.update(documentSigners)
.set({ status: 'signed', signedAt: new Date() })
.set({
status: 'signed',
// Preserve the original signedAt timestamp on duplicate webhook
// deliveries — overwriting it makes every signer card show the
// most-recent webhook timestamp instead of the actual sign time.
...(wasAlreadySigned ? {} : { signedAt: new Date() }),
})
.where(signerWhere)
.returning();
@@ -1083,13 +1167,21 @@ export async function handleRecipientSigned(eventData: {
.where(and(eq(documents.id, doc.id), eq(documents.portId, doc.portId)));
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'signed',
signerId: signer?.id ?? null,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail },
});
// Idempotent insert: Documenso v2 retries the same logical event with
// varying rawBody hashes, so the (documentId, hash:signed:email) unique
// index would otherwise throw on duplicate deliveries and short-circuit
// the cascade below. `onConflictDoNothing` treats the duplicate as the
// no-op it is.
await db
.insert(documentEvents)
.values({
documentId: doc.id,
eventType: 'signed',
signerId: signer?.id ?? null,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail },
})
.onConflictDoNothing();
emitToRoom(`port:${doc.portId}`, 'document:signer:signed', {
documentId: doc.id,
@@ -1098,9 +1190,10 @@ export async function handleRecipientSigned(eventData: {
// Phase 2 cascade: now that this signer is done, fire the branded
// "your turn" invitation to the next pending signer in signing order.
// The webhook may fire multiple times per document (one per recipient
// sign event); the `invitedAt` guard prevents duplicate invites.
if (signer) {
// Skip the cascade entirely on duplicate deliveries — only fire on
// the first pending→signed transition. The `invitedAt` guard inside
// sendCascadingInviteForNextSigner is a second safety net.
if (signer && !wasAlreadySigned) {
await sendCascadingInviteForNextSigner(doc).catch((err) => {
// Cascading-invite failure is non-fatal — the webhook itself
// succeeded. The rep can manually click "Send invitation" if the
@@ -1289,7 +1382,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
let putStoragePath: string | null = null;
try {
const signedPdfBuffer = await downloadSignedPdf(eventData.documentId);
// Download by the stored Documenso ID (envelope_xxx on v2, numeric on
// v1) rather than `eventData.documentId` — webhooks deliver the v2
// numeric internal pk, but the download endpoint expects the public
// envelope_xxx string. Falls back to the webhook's value when the
// stored ID is somehow missing (e.g. legacy pre-#69 rows).
const downloadId = doc.documensoId ?? eventData.documentId;
const signedPdfBuffer = await downloadSignedPdf(downloadId, doc.portId);
// Guard: a 0-byte response from Documenso would otherwise persist a
// permanent corrupt signedFileId pointing at a blob with no content.
@@ -1503,13 +1602,18 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
}
// Stage stays at 'eoi' — sub-status flips to signed.
void advanceStageIfBehind(
// EOI signed = formal commitment to proceed → advance to 'reservation'
// (the next milestone). Conventional CRM behaviour: stage reflects the
// deal's CURRENT pursuit phase, not the most recently signed document.
// Per-port admins can override the rule via the `eoi_signed` entry in
// the stage_advance_rules setting (auto / suggest / off).
void advanceStageIfBehindGated(
doc.interestId,
doc.portId,
'eoi',
'reservation',
systemMeta,
'EOI signed via Documenso',
'eoi_signed',
);
}
}
@@ -1533,12 +1637,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
void advanceStageIfBehind(
void advanceStageIfBehindGated(
doc.interestId,
doc.portId,
'reservation',
systemMeta,
'Reservation agreement signed',
'reservation_signed',
);
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
@@ -1563,12 +1668,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
void advanceStageIfBehind(
void advanceStageIfBehindGated(
doc.interestId,
doc.portId,
'contract',
systemMeta,
'Contract signed via Documenso',
'contract_signed',
);
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
@@ -1717,13 +1823,16 @@ export async function handleDocumentOpened(eventData: {
.where(eq(documentSigners.id, signer.id));
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'viewed',
signerId: signer?.id ?? null,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail },
});
await db
.insert(documentEvents)
.values({
documentId: doc.id,
eventType: 'viewed',
signerId: signer?.id ?? null,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail },
})
.onConflictDoNothing();
emitToRoom(`port:${doc.portId}`, 'document:signer:opened', {
documentId: doc.id,
@@ -1767,13 +1876,16 @@ export async function handleDocumentRejected(eventData: {
.where(and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)));
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'rejected',
signerId,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail ?? null },
});
await db
.insert(documentEvents)
.values({
documentId: doc.id,
eventType: 'rejected',
signerId,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail ?? null },
})
.onConflictDoNothing();
emitToRoom(`port:${doc.portId}`, 'document:rejected', {
documentId: doc.id,
@@ -1801,12 +1913,15 @@ export async function handleDocumentCancelled(eventData: {
.where(and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)));
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'cancelled',
signatureHash: eventData.signatureHash ?? null,
eventData: { documensoId: eventData.documentId },
});
await db
.insert(documentEvents)
.values({
documentId: doc.id,
eventType: 'cancelled',
signatureHash: eventData.signatureHash ?? null,
eventData: { documensoId: eventData.documentId },
})
.onConflictDoNothing();
emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id });
}
@@ -1865,10 +1980,20 @@ export async function getDocumentDetail(id: string, portId: string): Promise<Doc
* The actual Documenso void call lands in PR2 (`documenso-client.voidDocument`);
* this skeleton updates DB state only.
*/
export interface CancelDocumentOptions {
/** Rep-authored reason inlined into notification emails + audit log. */
reason?: string | null;
/** Document_signers ids the rep wants to email about the cancellation.
* Empty list = silent void (Regenerate flow). Each id is validated to
* belong to this document before any email fires. */
notifyRecipients?: string[];
}
export async function cancelDocument(
documentId: string,
portId: string,
meta: AuditMeta,
options: CancelDocumentOptions = {},
): Promise<typeof documents.$inferSelect> {
const existing = await getDocumentById(documentId, portId);
@@ -1900,7 +2025,11 @@ export async function cancelDocument(
await db.insert(documentEvents).values({
documentId,
eventType: 'cancelled',
eventData: { initiatedBy: meta.userId },
eventData: {
initiatedBy: meta.userId,
reason: options.reason ?? null,
notifyCount: options.notifyRecipients?.length ?? 0,
},
});
void createAuditLog({
@@ -1910,13 +2039,58 @@ export async function cancelDocument(
entityType: 'document',
entityId: documentId,
oldValue: { status: existing.status },
newValue: { status: 'cancelled' },
newValue: { status: 'cancelled', reason: options.reason ?? null },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'document:cancelled', { documentId });
// Notify selected signers (rep-picked subset via the cancel-with-notify
// modal). Pull the matching signer rows so we can render the recipient's
// canonical name; skip silently when the rep passed no ids (Regenerate
// flow). Failure to send is logged + non-fatal — the cancellation has
// already committed locally.
const notifyIds = options.notifyRecipients ?? [];
if (notifyIds.length > 0) {
try {
const rows = await db
.select({
id: documentSigners.id,
signerName: documentSigners.signerName,
signerEmail: documentSigners.signerEmail,
})
.from(documentSigners)
.where(
and(eq(documentSigners.documentId, documentId), inArray(documentSigners.id, notifyIds)),
);
if (rows.length > 0) {
const portRow = await db.query.ports.findFirst({
where: eq(ports.id, portId),
columns: { name: true },
});
const { sendSigningCancelled } =
await import('@/lib/services/document-signing-emails.service');
await sendSigningCancelled({
portId,
portName: portRow?.name ?? 'Port Nimara',
recipients: rows.map((r) => ({ name: r.signerName, email: r.signerEmail })),
documentLabel:
(DOC_TYPE_LABEL[existing.documentType] as
| 'Expression of Interest'
| 'Sales Contract'
| 'Reservation Agreement') ?? 'Expression of Interest',
reason: options.reason ?? null,
});
}
} catch (err) {
logger.error(
{ err, documentId, notifyIds },
'cancel-with-notify email fan-out failed; cancellation already committed',
);
}
}
return updated!;
}

View File

@@ -87,6 +87,7 @@ export async function toggleAccount(
accountId: string,
userId: string,
data: ToggleAccountInput,
audit?: AuditMeta,
): Promise<AccountWithoutCredentials> {
const existing = await db.query.emailAccounts.findFirst({
where: eq(emailAccounts.id, accountId),
@@ -112,6 +113,25 @@ export async function toggleAccount(
internalMessage: 'Failed to update email account',
});
// H-05: enable/disable used to land silently between connect/disconnect.
// Audit-trail this so an admin can see the toggle history (silently
// disabling an account suppresses bounce detection or reroutes replies —
// compliance-relevant change).
if (audit) {
void createAuditLog({
userId: audit.userId,
portId: audit.portId,
action: 'update',
entityType: 'email_account',
entityId: accountId,
oldValue: { isActive: existing.isActive },
newValue: { isActive: updated.isActive },
metadata: { emailAddress: existing.emailAddress },
ipAddress: audit.ipAddress,
userAgent: audit.userAgent,
});
}
return stripCredentials(updated);
}

View File

@@ -21,7 +21,19 @@ export type EoiContext = {
nationality: string | null;
primaryEmail: string | null;
primaryPhone: string | null;
address: { street: string; city: string; country: string } | null;
address: {
street: string;
city: string;
/** ISO-3166-2 subdivision code stripped of its country prefix
* (e.g. 'NY' from 'US-NY'). Empty string when not set. */
subdivision: string;
postalCode: string;
/** Localised country name — for the deprecated UI preview line only. */
country: string;
/** ISO-3166-1 alpha-2 country code — what the EOI Address field renders
* (e.g. 'US'), not the long name. */
countryIso: string;
} | null;
};
/** Optional. The EOI's Section 3 yacht block is left blank when null. */
yacht: {
@@ -33,6 +45,11 @@ export type EoiContext = {
lengthM: string | null;
widthM: string | null;
draftM: string | null;
/** Which unit the rep originally typed in. Drives the EOI drawer's
* default unit toggle so a metric-entered yacht doesn't render as ft. */
lengthUnit: 'ft' | 'm';
widthUnit: 'ft' | 'm';
draftUnit: 'ft' | 'm';
hullNumber: string | null;
flag: string | null;
yearBuilt: number | null;
@@ -181,17 +198,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
.select({
streetAddress: clientAddresses.streetAddress,
city: clientAddresses.city,
subdivisionIso: clientAddresses.subdivisionIso,
postalCode: clientAddresses.postalCode,
countryIso: clientAddresses.countryIso,
})
.from(clientAddresses)
.where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)))
.limit(1);
// The EOI's Address field renders the subdivision as the ISO-3166-2 SUFFIX
// (e.g. 'NY' from 'US-NY') and the country as the alpha-2 code ('US') so
// the line is short enough to fit. The localised country name is kept
// alongside for legacy callers and the in-app preview.
const clientAddress = primaryAddress
? {
street: primaryAddress.streetAddress ?? '',
city: primaryAddress.city ?? '',
subdivision: primaryAddress.subdivisionIso
? (primaryAddress.subdivisionIso.split('-').pop() ?? '')
: '',
postalCode: primaryAddress.postalCode ?? '',
country: primaryAddress.countryIso ? getCountryName(primaryAddress.countryIso, 'en') : '',
countryIso: primaryAddress.countryIso ?? '',
}
: null;
@@ -294,6 +322,9 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
lengthM: yacht.lengthM,
widthM: yacht.widthM,
draftM: yacht.draftM,
lengthUnit: (yacht.lengthUnit ?? 'ft') as 'ft' | 'm',
widthUnit: (yacht.widthUnit ?? 'ft') as 'ft' | 'm',
draftUnit: (yacht.draftUnit ?? 'ft') as 'ft' | 'm',
hullNumber: yacht.hullNumber,
flag: yacht.flag,
yearBuilt: yacht.yearBuilt,

View File

@@ -52,7 +52,7 @@ export async function getPrimaryBerth(interestId: string): Promise<PrimaryBerthR
mooringNumber: berths.mooringNumber,
})
.from(interestBerths)
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(eq(interestBerths.interestId, interestId))
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
const first = rows[0];
@@ -84,7 +84,7 @@ export async function getPrimaryBerthsForInterests(
mooringNumber: berths.mooringNumber,
})
.from(interestBerths)
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(inArray(interestBerths.interestId, interestIds))
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
@@ -101,11 +101,13 @@ export async function getPrimaryBerthsForInterests(
return out;
}
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}. */
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}.
* All berth-derived fields are nullable so an orphaned junction row (berth
* hard-deleted out from under the link) still renders rather than vanishing. */
export interface InterestBerthWithDetails extends InterestBerth {
mooringNumber: string | null;
area: string | null;
status: string;
status: string | null;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
@@ -137,7 +139,7 @@ export async function listBerthsForInterest(
draftFt: berths.draftFt,
})
.from(interestBerths)
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(eq(interestBerths.interestId, interestId))
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
}

View File

@@ -479,14 +479,18 @@ export async function getInterestById(id: string, portId: string) {
// wa.me) so the header can render Email / Call / WhatsApp buttons without
// a second fetch, and the Documents tab can show the EOI prereq checklist.
const [emailContact] = await db
.select({ value: clientContacts.value })
.select({ id: clientContacts.id, value: clientContacts.value })
.from(clientContacts)
.where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email')))
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
.limit(1);
const [phoneContact] = await db
.select({ value: clientContacts.value, valueE164: clientContacts.valueE164 })
.select({
id: clientContacts.id,
value: clientContacts.value,
valueE164: clientContacts.valueE164,
})
.from(clientContacts)
.where(
and(
@@ -528,14 +532,18 @@ export async function getInterestById(id: string, portId: string) {
// Most-recent note preview for the Overview tab (the "do you have anything
// outstanding on this lead?" peek). Returns the latest note's truncated
// content + author/timestamp so the UI can render a one-line teaser.
// Left-joins userProfiles so the teaser can show the author's display name
// instead of leaking the raw user-id UUID.
const [recentNote] = await db
.select({
id: interestNotes.id,
content: interestNotes.content,
authorId: interestNotes.authorId,
authorName: userProfiles.displayName,
createdAt: interestNotes.createdAt,
})
.from(interestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
.where(eq(interestNotes.interestId, id))
.orderBy(desc(interestNotes.createdAt))
.limit(1);
@@ -580,7 +588,11 @@ export async function getInterestById(id: string, portId: string) {
...interest,
clientName: clientRow?.fullName ?? null,
clientPrimaryEmail: emailContact?.value ?? null,
/** Contact-row id for the primary email — surfaces so the interest UI
* can inline-edit through PATCH /api/v1/clients/[id]/contacts/[contactId]. */
clientPrimaryEmailContactId: emailContact?.id ?? null,
clientPrimaryPhone: phoneContact?.value ?? null,
clientPrimaryPhoneContactId: phoneContact?.id ?? null,
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
clientHasAddress: !!addressRow,
berthId,
@@ -1010,6 +1022,68 @@ export async function advanceStageIfBehind(
return true;
}
/**
* Gated variant: reads the per-port `stage_advance_rules` setting for the
* given trigger and either:
* - 'auto' → calls advanceStageIfBehind (same behaviour as the base helper)
* - 'suggest' → emits an in-CRM notification with an Approve link so the
* rep can advance with one click (no auto-move)
* - 'off' → no-op (audit log of the event still fires upstream)
*
* Use this from every lifecycle event handler that wants admin-controlled
* cadence — the bare `advanceStageIfBehind` stays available for paths
* where the move is unconditional (manual rep action, completion of a
* doc the admin can't disable).
*/
export async function advanceStageIfBehindGated(
interestId: string,
portId: string,
target: PipelineStage,
meta: AuditMeta,
reason: string | undefined,
trigger:
| 'eoi_sent'
| 'eoi_signed'
| 'reservation_signed'
| 'deposit_received'
| 'contract_signed',
): Promise<boolean> {
const { getStageAdvanceMode } = await import('@/lib/services/port-config');
const mode = await getStageAdvanceMode(portId, trigger);
if (mode === 'off') return false;
if (mode === 'auto') return advanceStageIfBehind(interestId, portId, target, meta, reason);
// 'suggest' — notify the rep with an Approve link, no auto-move. The
// rep can click the notification to fire the same advance manually.
const existing = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
columns: { pipelineStage: true, assignedTo: true },
});
if (!existing) return false;
const currentIdx = PIPELINE_STAGES.indexOf(existing.pipelineStage as PipelineStage);
const targetIdx = PIPELINE_STAGES.indexOf(target);
if (currentIdx === -1 || targetIdx === -1 || currentIdx >= targetIdx) return false;
if (existing.assignedTo) {
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
createNotification({
portId,
userId: existing.assignedTo!,
type: 'stage_advance_suggested',
title: `Advance to ${target}?`,
description:
reason ??
`${trigger} fired — suggested advance from ${existing.pipelineStage} to ${target}.`,
link: `/interests/${interestId}`,
entityType: 'interest',
entityId: interestId,
dedupeKey: `interest:${interestId}:advance-suggest:${trigger}`,
}).catch(() => {
// Notification failure shouldn't block the parent webhook handler.
}),
);
}
return false;
}
// ─── Set Outcome (Won / Lost) ────────────────────────────────────────────────
//
// Records a terminal outcome for the interest. The `outcome` column is the
@@ -1047,12 +1121,13 @@ export async function setInterestOutcome(
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
// M-AU04: distinct verb so the audit filter / FTS surface it directly.
action: 'outcome_set',
entityType: 'interest',
entityId: id,
oldValue: { outcome: oldOutcome, pipelineStage: stageAtOutcome },
newValue: { outcome: data.outcome, pipelineStage: stageAtOutcome, reason: data.reason },
metadata: { type: 'outcome_set', stageAtOutcome },
metadata: { stageAtOutcome },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -1115,12 +1190,12 @@ export async function clearInterestOutcome(
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
// M-AU04: distinct verb so the audit filter / FTS surface it directly.
action: 'outcome_cleared',
entityType: 'interest',
entityId: id,
oldValue: { outcome: existing.outcome, pipelineStage: existing.pipelineStage },
newValue: { outcome: null, pipelineStage: reopenStage },
metadata: { type: 'outcome_cleared' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});

View File

@@ -680,13 +680,14 @@ export async function recordPayment(
// tracking for the deposit stage; this block stays wired for legacy
// invoices but new flows record payments via that pathway instead.
if (updated.kind === 'deposit' && updated.interestId) {
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
void advanceStageIfBehind(
const { advanceStageIfBehindGated } = await import('@/lib/services/interests.service');
void advanceStageIfBehindGated(
updated.interestId,
portId,
'deposit_paid',
meta,
`Deposit invoice ${existing.invoiceNumber} paid`,
'deposit_received',
);
// Deposit-paid also fires the berth-rule for `deposit_received` so admins

View File

@@ -105,6 +105,10 @@ export interface AggregatedClientNote {
/** Human label for the source (interest's berth mooring, yacht
* name, or "Client" for client-level). */
sourceLabel: string;
/** Pipeline stage the linked interest was at when this note was
* authored. Only populated for interest notes (the only entity with
* a pipeline). Null for pre-2026-05-15 rows + non-interest sources. */
pipelineStageAtCreation?: string | null;
}
export async function listForClientAggregated(
@@ -175,6 +179,7 @@ export async function listForClientAggregated(
authorId: interestNotes.authorId,
authorName: userProfiles.displayName,
sourceId: interestNotes.interestId,
pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
})
.from(interestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
@@ -278,7 +283,11 @@ export async function listForYachtAggregated(
? await db
.select({ id: clients.id, name: clients.fullName })
.from(clients)
.where(eq(clients.id, ownerClientId))
// M-MT04: defense-in-depth port_id filter — without it a stale
// ownerClientId persisted by a prior cross-port migration could
// surface the wrong tenant's client name. Belt-and-braces given
// yacht ownership is polymorphic via a non-FK pair.
.where(and(eq(clients.id, ownerClientId), eq(clients.portId, portId)))
.limit(1)
: [];
@@ -340,6 +349,7 @@ export async function listForYachtAggregated(
authorId: interestNotes.authorId,
authorName: userProfiles.displayName,
sourceId: interestNotes.interestId,
pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
})
.from(interestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
@@ -456,6 +466,7 @@ export async function listForCompanyAggregated(
authorId: interestNotes.authorId,
authorName: userProfiles.displayName,
sourceId: interestNotes.interestId,
pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
})
.from(interestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
@@ -590,6 +601,7 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
createdAt: interestNotes.createdAt,
updatedAt: interestNotes.updatedAt,
authorName: userProfiles.displayName,
pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
})
.from(interestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
@@ -748,9 +760,22 @@ export async function create(
return { ...note, authorName };
}
if (entityType === 'interests') {
// Snapshot the linked interest's current pipeline_stage so the note
// carries the stage it was authored in. Powers the stage chip on the
// NotesList timeline. Missing-interest is treated as a no-stamp
// create rather than a hard failure.
const interestRow = await db.query.interests.findFirst({
where: eq(interests.id, entityId),
columns: { pipelineStage: true },
});
const [note] = await db
.insert(interestNotes)
.values({ interestId: entityId, authorId, content: data.content })
.values({
interestId: entityId,
authorId,
content: data.content,
pipelineStageAtCreation: interestRow?.pipelineStage ?? null,
})
.returning();
if (!note)
@@ -846,7 +871,10 @@ export async function update(
const [updated] = await db
.update(yachtNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(yachtNotes.id, noteId))
// M-MT02: defense-in-depth — pin the UPDATE to the (id, parent) pair
// so a swapped noteId can't land on a sibling yacht's note even if
// the existing read above already validated ownership.
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
@@ -869,7 +897,8 @@ export async function update(
const [updated] = await db
.update(companyNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(companyNotes.id, noteId))
// M-MT02: pin (id, parent) for defense-in-depth.
.where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
@@ -897,7 +926,8 @@ export async function update(
const [updated] = await db
.update(clientNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(clientNotes.id, noteId))
// M-MT02: pin (id, parent) for defense-in-depth.
.where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId)))
.returning();
if (!updated) throw new NotFoundError('Note');
@@ -928,7 +958,13 @@ export async function update(
const [updated] = await db
.update(residentialClientNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(residentialClientNotes.id, noteId))
// M-MT02: pin (id, parent) for defense-in-depth.
.where(
and(
eq(residentialClientNotes.id, noteId),
eq(residentialClientNotes.residentialClientId, entityId),
),
)
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
@@ -956,7 +992,13 @@ export async function update(
const [updated] = await db
.update(residentialInterestNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(residentialInterestNotes.id, noteId))
// M-MT02: pin (id, parent) for defense-in-depth.
.where(
and(
eq(residentialInterestNotes.id, noteId),
eq(residentialInterestNotes.residentialInterestId, entityId),
),
)
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
@@ -982,7 +1024,8 @@ export async function update(
const [updated] = await db
.update(interestNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(interestNotes.id, noteId))
// M-MT02: pin (id, parent) for defense-in-depth.
.where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId)))
.returning();
if (!updated) throw new NotFoundError('Note');
@@ -1015,7 +1058,10 @@ export async function deleteNote(
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(yachtNotes).where(eq(yachtNotes.id, noteId));
// M-MT02: pin (id, parent) on the delete WHERE for defense-in-depth.
await db
.delete(yachtNotes)
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)));
return existing;
}
if (entityType === 'companies') {
@@ -1028,7 +1074,10 @@ export async function deleteNote(
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(companyNotes).where(eq(companyNotes.id, noteId));
// M-MT02: pin (id, parent).
await db
.delete(companyNotes)
.where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)));
return existing;
}
if (entityType === 'clients') {
@@ -1043,7 +1092,10 @@ export async function deleteNote(
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(clientNotes).where(eq(clientNotes.id, noteId));
// M-MT02: pin (id, parent).
await db
.delete(clientNotes)
.where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId)));
return existing;
}
if (entityType === 'residential_clients') {
@@ -1061,7 +1113,15 @@ export async function deleteNote(
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(residentialClientNotes).where(eq(residentialClientNotes.id, noteId));
// M-MT02: pin (id, parent).
await db
.delete(residentialClientNotes)
.where(
and(
eq(residentialClientNotes.id, noteId),
eq(residentialClientNotes.residentialClientId, entityId),
),
);
return existing;
}
if (entityType === 'residential_interests') {
@@ -1079,7 +1139,15 @@ export async function deleteNote(
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(residentialInterestNotes).where(eq(residentialInterestNotes.id, noteId));
// M-MT02: pin (id, parent).
await db
.delete(residentialInterestNotes)
.where(
and(
eq(residentialInterestNotes.id, noteId),
eq(residentialInterestNotes.residentialInterestId, entityId),
),
);
return existing;
}
// Default: interests
@@ -1095,7 +1163,10 @@ export async function deleteNote(
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(interestNotes).where(eq(interestNotes.id, noteId));
// M-MT02: pin (id, parent).
await db
.delete(interestNotes)
.where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId)));
return existing;
}
}

View File

@@ -155,15 +155,11 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
{ branding },
);
// The per-port subject override key for the digest is the
// existing 'crm_invite' / 'portal_*' family — digest is its own
// thing; for now we ship the default subject from the template.
// M-EM04: dedicated catalog key — admins can override the
// digest subject from /admin/email without an unsafe cast and
// the digest's setting key now namespaces cleanly.
const subject = await resolveSubject({
// No dedicated catalog key yet for the digest; keep a stable
// pseudo-key in case admins want to override later. Falls
// through to the template's default subject if no override.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
key: 'crm_invite' as any,
key: 'notification_digest',
portId: port.id,
fallback: result.subject,
tokens: { portName: port.name },

View File

@@ -130,13 +130,14 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me
const { total } = await getDepositTotalForInterest(data.interestId, portId);
const expected = interest.depositExpectedAmount ? Number(interest.depositExpectedAmount) : null;
if (expected !== null && Number.isFinite(expected) && Number(total) >= expected) {
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
void advanceStageIfBehind(
const { advanceStageIfBehindGated } = await import('@/lib/services/interests.service');
void advanceStageIfBehindGated(
data.interestId,
portId,
'deposit_paid',
meta,
`Deposit total (${total} ${data.currency}) reached expected amount`,
'deposit_received',
);
// Stamp dateDepositReceived if not already set so the timeline shows

View File

@@ -105,14 +105,103 @@ export const SETTING_KEYS = {
// Berths
berthsDefaultCurrency: 'berths_default_currency',
// Pipeline auto-advance — per-trigger mode (auto | suggest | off).
// Stored as a single JSON blob keyed by trigger name so the admin UI
// edits/saves the full map atomically. Defaults applied in
// `getStageAdvanceMode` — aggressive defaults match the conventional
// CRM behaviour (EOI signed → reservation auto-advances).
stageAdvanceRules: 'stage_advance_rules',
} as const;
// ─── Stage auto-advance ──────────────────────────────────────────────────────
export type StageAdvanceMode = 'auto' | 'suggest' | 'off';
/**
* Stage transitions that callers can gate through the admin's
* `stage_advance_rules` setting. Keys are the trigger names already
* used by the rules engine (`berth-rules-engine.ts`) — keeping them in
* sync lets a single admin toggle drive both side-effects (berth status)
* and stage moves.
*/
export type StageAdvanceTrigger =
| 'eoi_sent'
| 'eoi_signed'
| 'reservation_signed'
| 'deposit_received'
| 'contract_signed';
const STAGE_ADVANCE_DEFAULTS: Record<StageAdvanceTrigger, StageAdvanceMode> = {
// Sending the EOI is the moment the deal formally enters the document-
// signing pursuit phase — auto-advance so the kanban tracks reality
// without a rep having to click.
eoi_sent: 'auto',
// EOI signed = formal commitment to proceed → advance to reservation.
eoi_signed: 'auto',
// Reservation signed = the deal is now under contract pursuit.
reservation_signed: 'auto',
// Deposit received = monies in, deal is committed; the next milestone
// is contract sign-off.
deposit_received: 'auto',
contract_signed: 'auto',
};
/**
* Resolve the auto-advance mode for a single trigger on a port.
* Reads `stage_advance_rules` (a JSON object keyed by trigger name) and
* falls back to the platform default when the port hasn't overridden.
*/
export async function getStageAdvanceMode(
portId: string,
trigger: StageAdvanceTrigger,
): Promise<StageAdvanceMode> {
const raw = await readSetting<Record<string, StageAdvanceMode>>(
SETTING_KEYS.stageAdvanceRules,
portId,
);
const mode = raw?.[trigger];
if (mode === 'auto' || mode === 'suggest' || mode === 'off') return mode;
return STAGE_ADVANCE_DEFAULTS[trigger];
}
export function getStageAdvanceDefaults(): Record<StageAdvanceTrigger, StageAdvanceMode> {
return { ...STAGE_ADVANCE_DEFAULTS };
}
// ─── Helper ──────────────────────────────────────────────────────────────────
async function readSetting<T>(key: string, portId: string): Promise<T | null> {
/**
* Resolves a port-scoped setting through the registry-aware resolver, which
* applies the port → global → env → registry-default chain and decrypts
* encrypted entries transparently. The legacy `{ value: <T> }` wrapper from
* `settings.service.upsertSetting` is unwrapped inside the resolver, so this
* helper still returns the bare `T | null` shape its callers expect.
*
* If the key isn't registered yet (legacy bespoke settings not in the
* registry), we fall back to the older `settings.service.getSetting()` path
* for backward compatibility.
*/
export async function readSetting<T>(key: string, portId: string): Promise<T | null> {
const { registryFor } = await import('@/lib/settings/registry');
if (registryFor(key)) {
const { getSetting: getSettingFromRegistry } = await import('@/lib/settings/resolver');
return (await getSettingFromRegistry<T>(key, portId)) ?? null;
}
const setting = await getSetting(key, portId);
if (!setting) return null;
return setting.value as T;
// Legacy upsertSetting wrote the value as the JSONB column directly (not
// wrapped). Newer paths wrap it as `{ value }`. Tolerate both.
const raw = setting.value as unknown;
if (
raw &&
typeof raw === 'object' &&
'value' in (raw as Record<string, unknown>) &&
Object.keys(raw as object).length === 1
) {
return (raw as { value: T }).value;
}
return raw as T;
}
// ─── Email ──────────────────────────────────────────────────────────────────
@@ -171,8 +260,8 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
fromName: fromName ?? envFromName,
fromAddress: fromAddress ?? envFromAddress,
replyTo: replyTo ?? null,
smtpHost: smtpHost ?? env.SMTP_HOST,
smtpPort: smtpPort ?? env.SMTP_PORT,
smtpHost: smtpHost ?? env.SMTP_HOST ?? '',
smtpPort: smtpPort ?? env.SMTP_PORT ?? 587,
smtpUser: smtpUser ?? env.SMTP_USER ?? null,
smtpPass: smtpPass ?? env.SMTP_PASS ?? null,
allowPersonalAccountSends: allowPersonalAccountSends ?? false,
@@ -302,13 +391,18 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
]);
return {
apiUrl: apiUrl ?? env.DOCUMENSO_API_URL,
apiKey: apiKey ?? env.DOCUMENSO_API_KEY,
// Env values are now optional (admin is canonical). Empty / zero
// defaults let consumers proceed and fail at the actual API call with
// a clearer "not configured" error rather than crashing at type-check.
apiUrl: apiUrl ?? env.DOCUMENSO_API_URL ?? '',
apiKey: apiKey ?? env.DOCUMENSO_API_KEY ?? '',
apiVersion: apiVersion ?? env.DOCUMENSO_API_VERSION,
eoiTemplateId: toIntOrNull(eoiTemplateId) ?? env.DOCUMENSO_TEMPLATE_ID_EOI,
clientRecipientId: toIntOrNull(clientRecipientId) ?? env.DOCUMENSO_CLIENT_RECIPIENT_ID,
developerRecipientId: toIntOrNull(developerRecipientId) ?? env.DOCUMENSO_DEVELOPER_RECIPIENT_ID,
approvalRecipientId: toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID,
eoiTemplateId: toIntOrNull(eoiTemplateId) ?? env.DOCUMENSO_TEMPLATE_ID_EOI ?? 0,
clientRecipientId: toIntOrNull(clientRecipientId) ?? env.DOCUMENSO_CLIENT_RECIPIENT_ID ?? 0,
developerRecipientId:
toIntOrNull(developerRecipientId) ?? env.DOCUMENSO_DEVELOPER_RECIPIENT_ID ?? 0,
approvalRecipientId:
toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0,
defaultPathway: defaultPathway ?? 'documenso-template',
developerName: developerName ?? '',
developerEmail: developerEmail ?? '',
@@ -344,17 +438,40 @@ export interface DocumensoSecretEntry {
export async function listDocumensoWebhookSecrets(): Promise<DocumensoSecretEntry[]> {
const { db } = await import('@/lib/db');
const { systemSettings } = await import('@/lib/db/schema/system');
const { eq, isNotNull } = await import('drizzle-orm');
const { decrypt } = await import('@/lib/utils/encryption');
const { eq } = await import('drizzle-orm');
const rows = await db
.select({ portId: systemSettings.portId, value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, SETTING_KEYS.documensoWebhookSecret));
void isNotNull; // imported for future filters
const out: DocumensoSecretEntry[] = [];
for (const row of rows) {
if (typeof row.value !== 'string' || !row.value || !row.portId) continue;
out.push({ portId: row.portId, secret: row.value });
if (!row.portId) continue;
let secret: string | null = null;
if (typeof row.value === 'string') {
// Legacy plaintext rows.
secret = row.value;
} else if (
typeof row.value === 'object' &&
row.value !== null &&
'iv' in row.value &&
'tag' in row.value &&
'data' in row.value
) {
// Encrypted envelope written by the registry-aware resolver.
try {
secret = decrypt(JSON.stringify(row.value));
} catch {
// Decryption failure (corrupt envelope, key mismatch) — skip the
// entry so a stale row doesn't crash the entire receiver loop.
secret = null;
}
}
if (!secret) continue;
out.push({ portId: row.portId, secret });
}
// Append the global env secret as a fallback ONLY when it's a real,
// non-empty value. An empty env secret would otherwise match an empty
// X-Documenso-Secret header (verifyDocumensoSecret guards this too,

View File

@@ -161,7 +161,10 @@ async function issueActivationToken(
);
try {
await sendEmail(email, subject, html, undefined, text);
// M-EM01: pass portId so per-port SMTP is used. Without it the call
// falls back to the global SMTP transport and the from-address won't
// carry the port's branding.
await sendEmail(email, subject, html, undefined, text, portId);
} catch (err) {
logger.error({ err, email }, 'Failed to send portal activation email');
// Re-throw - the admin should know if their invite mail bounced.
@@ -425,7 +428,8 @@ export async function requestPasswordReset(email: string): Promise<void> {
);
try {
await sendEmail(user.email, subject, html, undefined, text);
// M-EM01: pass portId so per-port SMTP + from-address are used.
await sendEmail(user.email, subject, html, undefined, text, user.portId);
} catch (err) {
logger.error({ err, email: user.email }, 'Failed to send password-reset email');
// Don't propagate - the public route returns 200 either way.

View File

@@ -348,7 +348,11 @@ export async function getDocumentDownloadUrl(
if (!file) return null;
return presignDownloadUrl(file.storagePath);
// M-IN01: 4-hour TTL for portal links so clients clicking on a saved
// email don't see "link expired" after 15 minutes. Storage backend's
// default is 900s — that's appropriate for in-CRM previews but
// hostile for end-clients who may revisit a doc the next morning.
return presignDownloadUrl(file.storagePath, 4 * 3600);
}
// ─── Yachts (direct + via company) ────────────────────────────────────────────

View File

@@ -214,6 +214,21 @@ export interface QualificationRow {
confirmedAt: Date | null;
confirmedBy: string | null;
notes: string | null;
/**
* True when the criterion is automatically satisfied by data already on
* the interest (or a linked record). Surfaced to the UI so the checklist
* can render the criterion as ticked-by-the-system without needing an
* explicit `interest_qualifications` row. When both `autoSatisfied` and
* the explicit `confirmed` are true, either signal alone counts.
*
* Auto-satisfaction rules:
* - `dimensions` → ticked when EITHER (a) the linked yacht has all
* three dims (length/width/draft) OR (b) the interest itself has
* desired-berth dims set. The "no yacht needed" case is the second
* branch — a client buying a berth doesn't have to own a vessel,
* they just have to know the berth size they want.
*/
autoSatisfied: boolean;
}
/**
@@ -226,12 +241,38 @@ export async function listInterestQualifications(
interestId: string,
portId: string,
): Promise<QualificationRow[]> {
// Pull the interest row with the fields needed to derive auto-satisfaction —
// desired-berth dims (length/width/draft) plus a linked yacht if any. Cost
// is one extra column-select vs the previous columns:{id:true} probe, so
// negligible.
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
columns: { id: true },
columns: {
id: true,
yachtId: true,
desiredLengthFt: true,
desiredWidthFt: true,
desiredDraftFt: true,
},
});
if (!interest) throw new NotFoundError('Interest');
// Pull the linked yacht's dims when one is attached. Used by the
// `dimensions` criterion's auto-satisfaction rule (yacht-side branch).
let yachtDims: {
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
} | null = null;
if (interest.yachtId) {
const { yachts } = await import('@/lib/db/schema/yachts');
const yacht = await db.query.yachts.findFirst({
where: eq(yachts.id, interest.yachtId),
columns: { lengthFt: true, widthFt: true, draftFt: true },
});
if (yacht) yachtDims = yacht;
}
const criteria = await db
.select()
.from(qualificationCriteria)
@@ -246,20 +287,59 @@ export async function listInterestQualifications(
return criteria.map((c) => {
const s = stateByKey.get(c.key);
const autoSatisfied = computeAutoSatisfied(c.key, {
yachtDims,
desiredDims: {
lengthFt: interest.desiredLengthFt ?? null,
widthFt: interest.desiredWidthFt ?? null,
draftFt: interest.desiredDraftFt ?? null,
},
});
const explicit = s?.confirmed ?? false;
return {
key: c.key,
label: c.label,
description: c.description,
enabled: c.enabled,
displayOrder: c.displayOrder,
confirmed: s?.confirmed ?? false,
// Surface ticked state when either signal is true. Explicit confirmation
// still gets its confirmedAt/By stamps; auto-satisfied state leaves
// those null so the rep can see "this was system-derived, not an
// explicit sign-off".
confirmed: explicit || autoSatisfied,
confirmedAt: s?.confirmedAt ?? null,
confirmedBy: s?.confirmedBy ?? null,
notes: s?.notes ?? null,
autoSatisfied,
};
});
}
/**
* Per-criterion derivation rules. Each key knows how to read the interest
* (and any linked records) and decide whether the criterion is satisfied
* without an explicit rep tick. Add new rules by branching on `key`.
*/
function computeAutoSatisfied(
key: string,
ctx: {
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
},
): boolean {
if (key === 'dimensions') {
const hasYachtDims =
!!ctx.yachtDims &&
!!ctx.yachtDims.lengthFt &&
!!ctx.yachtDims.widthFt &&
!!ctx.yachtDims.draftFt;
const hasDesiredDims =
!!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
return hasYachtDims || hasDesiredDims;
}
return false;
}
/**
* Upsert a single criterion's confirmed-state for an interest. Stamping the
* server-side fields (confirmedBy / confirmedAt) makes the row a proper

View File

@@ -1,7 +1,23 @@
import OpenAI from 'openai';
import { logger } from '@/lib/logger';
import { env } from '@/lib/env';
const openai = new OpenAI(); // uses OPENAI_API_KEY from env
// M-IN02: lazy-instantiate so a missing/invalid OPENAI_API_KEY doesn't
// fail boot — the receipt-scan path is opt-in and only some ports
// will have OCR configured. Cached after first construction so we
// don't pay the cost on every scan.
let openaiClient: OpenAI | null = null;
function getOpenAI(): OpenAI {
if (!openaiClient) {
if (!env.OPENAI_API_KEY) {
throw new Error(
'OPENAI_API_KEY is not configured — receipt OCR is unavailable. Set the key in /admin/ai or .env.',
);
}
openaiClient = new OpenAI({ apiKey: env.OPENAI_API_KEY });
}
return openaiClient;
}
interface ScanResult {
establishment: string | null;
@@ -15,7 +31,7 @@ interface ScanResult {
export async function scanReceipt(imageBuffer: Buffer, mimeType: string): Promise<ScanResult> {
try {
const base64 = imageBuffer.toString('base64');
const response = await openai.chat.completions.create({
const response = await getOpenAI().chat.completions.create({
model: 'gpt-4o',
messages: [
{

View File

@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { auditLogs, systemSettings } from '@/lib/db/schema/system';
import { STAGE_WEIGHTS } from '@/lib/constants';
import { STAGE_WEIGHTS, canonicalizeStage } from '@/lib/constants';
import { activeInterestsWhere } from '@/lib/services/active-interest';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -75,9 +75,14 @@ export async function fetchPipelineData(
.where(activeInterestsWhere(portId))
.groupBy(interests.pipelineStage);
// M-L02: legacy 9-stage values (deposit_10pct, contract_sent…) may
// still be present on historical rows. canonicalizeStage maps them
// back to the modern 7-stage keys so the rollup doesn't carry phantom
// buckets through to the PDF.
const stageCountMap: Record<string, number> = {};
for (const row of stageCounts) {
stageCountMap[row.stage] = row.count;
const key = canonicalizeStage(row.stage);
stageCountMap[key] = (stageCountMap[key] ?? 0) + row.count;
}
// Top 10 interests by berth price (via primary-berth junction join, plan §3.4).
@@ -103,7 +108,9 @@ export async function fetchPipelineData(
topInterests: topInterestsRows.map((r) => ({
id: r.id,
clientId: r.clientId,
pipelineStage: r.pipelineStage,
// M-L02: canonicalize for the same reason — the PDF stage label
// should always resolve from the modern 7-stage set.
pipelineStage: canonicalizeStage(r.pipelineStage),
berthPrice: r.berthPrice ? String(r.berthPrice) : null,
})),
generatedAt: new Date().toISOString(),
@@ -133,9 +140,13 @@ export async function fetchRevenueData(
.where(activeInterestsWhere(portId))
.groupBy(interests.pipelineStage);
// M-L02: canonicalize so legacy 9-stage rows fold into the modern bucket.
const stageRevenueMap: Record<string, string> = {};
for (const row of stageRevenue) {
stageRevenueMap[row.stage] = row.revenue ? String(row.revenue) : '0';
const key = canonicalizeStage(row.stage);
const prior = parseFloat(stageRevenueMap[key] ?? '0');
const next = row.revenue ? parseFloat(String(row.revenue)) : 0;
stageRevenueMap[key] = String(prior + next);
}
// Total revenue from WON interests only. Reporting audit caught the
@@ -188,7 +199,10 @@ export async function fetchRevenueData(
let totalForecast = 0;
for (const row of forecastRows) {
if (!row.revenue) continue;
const weight = pipelineWeights[row.stage] ?? 0;
// M-L02: canonicalize so legacy keys hit pipelineWeights via their
// modern equivalent (otherwise the lookup falls through to 0 and the
// forecast silently undershoots).
const weight = pipelineWeights[canonicalizeStage(row.stage)] ?? 0;
totalForecast += parseFloat(String(row.revenue)) * weight;
}

View File

@@ -22,6 +22,7 @@ import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '@/lib/env';
import { ConflictError } from '@/lib/errors';
import { decrypt, encrypt } from '@/lib/utils/encryption';
import { SMTP_TIMEOUTS } from '@/lib/email';
import { getSetting, upsertSetting } from '@/lib/services/settings.service';
import type { AuditMeta } from '@/lib/audit';
@@ -332,6 +333,11 @@ export async function createSalesTransporter(portId: string): Promise<{
host: cfg.smtpHost,
port: cfg.smtpPort,
secure: cfg.smtpSecure,
// H-08: cap connection / greeting / socket timeouts so a hung SMTP
// relay can't stall the BullMQ email queue indefinitely (default
// would let a single flaky upstream block every worker slot for
// ~10 min before the retry policy gives up).
...SMTP_TIMEOUTS,
...(cfg.smtpUser && cfg.smtpPass ? { auth: { user: cfg.smtpUser, pass: cfg.smtpPass } } : {}),
});
return { transporter, fromAddress: cfg.fromAddress, authedUser: cfg.smtpUser };

View File

@@ -468,7 +468,7 @@ async function searchClients(
fullName: r.full_name,
matchedContact: r.matched_value ?? null,
matchedContactChannel: r.matched_channel ?? null,
archivedAt: r.archived_at ? r.archived_at.toISOString() : null,
archivedAt: r.archived_at ? new Date(r.archived_at).toISOString() : null,
matchedOn,
};
});
@@ -1032,7 +1032,10 @@ async function searchReminders(
const rows = await db.execute<{
id: string;
title: string;
due_at: Date;
// postgres.js returns timestamptz as a string for raw db.execute calls
// (no schema-aware decoding like the query builder gets), so type as
// `string | Date` and normalize at the boundary.
due_at: string | Date;
priority: string;
status: string;
}>(sql`
@@ -1051,7 +1054,7 @@ async function searchReminders(
return Array.from(rows).map((r) => ({
id: r.id,
title: r.title,
dueAt: r.due_at.toISOString(),
dueAt: new Date(r.due_at).toISOString(),
priority: r.priority,
status: r.status,
}));

View File

@@ -63,14 +63,27 @@ export async function upsertSetting(key: string, value: unknown, portId: string,
},
});
// H-06: keys ending with `_encrypted` carry AES-GCM ciphertext that's only
// useful with EMAIL_CREDENTIAL_KEY. Recording the ciphertext verbatim in
// audit_logs.new_value would turn the audit log (readable by any admin
// with `admin.view_audit_log`) into a credential bundle — if the
// encryption key is ever rotated/leaked the history exfils every
// configured password. Mask the value but keep the audit trail itself
// (who toggled what, when).
const isEncryptedKey = key.endsWith('_encrypted');
const auditOldValue = existing
? { value: isEncryptedKey ? '[redacted]' : existing.value }
: undefined;
const auditNewValue = { value: isEncryptedKey ? '[redacted]' : value };
void createAuditLog({
userId: meta.userId,
portId,
action: existing ? 'update' : 'create',
entityType: 'setting',
entityId: key,
oldValue: existing ? { value: existing.value } : undefined,
newValue: { value },
oldValue: auditOldValue,
newValue: auditNewValue,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -100,7 +113,7 @@ export async function deleteSetting(key: string, portId: string, meta: AuditMeta
action: 'delete',
entityType: 'setting',
entityId: key,
oldValue: { value: existing.value },
oldValue: { value: key.endsWith('_encrypted') ? '[redacted]' : existing.value },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});

View File

@@ -287,19 +287,30 @@ export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisi
/**
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
* authenticated endpoint that proves both auth + websiteId are good.
* Throws on any failure with a descriptive message; resolves on success.
*
* M-IN05: returns a tagged union `{ ok: true | false }` instead of throwing,
* matching the shape of `checkDocumensoHealth` / sales-email health probes.
* Routes that just want to surface a green/red "Test connection" pill no
* longer have to wrap the call in try/catch with hand-crafted error
* extraction.
*/
export async function testConnection(portId: string): Promise<{ ok: true; visitors: number }> {
export async function testConnection(
portId: string,
): Promise<{ ok: true; visitors: number } | { ok: false; error: string }> {
const config = await loadUmamiConfig(portId);
if (!config) {
throw new CodedError('UMAMI_NOT_CONFIGURED', {
internalMessage: 'Umami is not configured for this port.',
});
return { ok: false, error: 'Umami is not configured for this port.' };
}
try {
const result = await umamiFetch<UmamiActiveVisitors>(
config,
`/api/websites/${config.websiteId}/active`,
{},
);
return { ok: true, visitors: result.visitors };
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Umami request failed';
return { ok: false, error: message };
}
const result = await umamiFetch<UmamiActiveVisitors>(
config,
`/api/websites/${config.websiteId}/active`,
{},
);
return { ok: true, visitors: result.visitors };
}

View File

@@ -101,11 +101,15 @@ export async function listWebhooks(portId: string) {
// ─── Get Single ───────────────────────────────────────────────────────────────
export async function getWebhook(portId: string, webhookId: string) {
// M-MT05: portId in the WHERE so the row never leaves the DB if it
// belongs to a different tenant — the prior JS-side .portId !== portId
// check fired AFTER the row was already loaded, which a future timing-
// or audit-side-channel could exploit.
const webhook = await db.query.webhooks.findFirst({
where: eq(webhooks.id, webhookId),
where: and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)),
});
if (!webhook || webhook.portId !== portId) {
if (!webhook) {
throw new NotFoundError('Webhook');
}
@@ -130,11 +134,12 @@ export async function updateWebhook(
data: UpdateWebhookInput,
meta: AuditMeta,
) {
// M-MT05: portId in WHERE — same reasoning as getWebhook.
const existing = await db.query.webhooks.findFirst({
where: eq(webhooks.id, webhookId),
where: and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)),
});
if (!existing || existing.portId !== portId) {
if (!existing) {
throw new NotFoundError('Webhook');
}
@@ -167,11 +172,12 @@ export async function updateWebhook(
// ─── Delete ───────────────────────────────────────────────────────────────────
export async function deleteWebhook(portId: string, webhookId: string, meta: AuditMeta) {
// M-MT05: portId in WHERE — same reasoning as getWebhook.
const existing = await db.query.webhooks.findFirst({
where: eq(webhooks.id, webhookId),
where: and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)),
});
if (!existing || existing.portId !== portId) {
if (!existing) {
throw new NotFoundError('Webhook');
}

View File

@@ -0,0 +1,517 @@
import { z } from 'zod';
import type { SettingEntry } from './types';
/**
* Central registry of every tenant-configurable setting. One entry per setting,
* consumed by the resolver, the admin form generator, the validator, and the
* encryption helper. Adding a new integration is a registry entry — no new
* schema, no new resolver, no new admin page wiring.
*
* Do NOT register boot-time / build-time secrets here (DATABASE_URL,
* BETTER_AUTH_SECRET, NEXT_PUBLIC_*, etc.). Those stay in env.ts because
* they're needed before the DB is reachable or get baked into the client
* bundle at build time.
*
* Section naming convention: `<integration>.<group>` (e.g. `documenso.api`,
* `documenso.signers`, `email.smtp`). The admin form generator filters by
* section name, so keep them stable.
*/
export const REGISTRY: SettingEntry[] = [
// ─── Documenso API ────────────────────────────────────────────────────────
// Keys keep the existing `_override` suffix for the env-fallback fields so
// existing data + per-domain readers (`getPortDocumensoConfig` etc.) don't
// need a rename migration. Brand-new fields (webhook secret) use plain
// suffix-free keys.
{
key: 'documenso_api_url_override',
section: 'documenso.api',
label: 'API URL',
description:
'Bare host only — never include /api/v1. The client appends versioned paths based on the API version below.',
type: 'url',
scope: 'port',
envFallback: 'DOCUMENSO_API_URL',
placeholder: 'https://documenso.example.com',
},
{
key: 'documenso_api_key_override',
section: 'documenso.api',
label: 'API key',
description: 'AES-encrypted at rest. Only stored when set explicitly.',
type: 'password',
scope: 'port',
encrypted: true,
sensitive: true,
envFallback: 'DOCUMENSO_API_KEY',
},
{
key: 'documenso_api_version_override',
section: 'documenso.api',
label: 'API version',
description:
'v1 = Documenso 1.13.x stable. v2 = Documenso 2.x with the envelope model. Test the connection after switching.',
type: 'select',
options: [
{ value: 'v1', label: 'v1 — Documenso 1.13.x (default, stable)' },
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope, recommended for new ports)' },
],
scope: 'port',
envFallback: 'DOCUMENSO_API_VERSION',
defaultValue: 'v1',
},
{
key: 'documenso_webhook_secret',
section: 'documenso.api',
label: 'Webhook secret',
description:
'Verifies inbound webhook deliveries via the X-Documenso-Secret header (timing-safe compare). Generate with `openssl rand -hex 16`.',
type: 'password',
scope: 'port',
encrypted: true,
sensitive: true,
envFallback: 'DOCUMENSO_WEBHOOK_SECRET',
validator: z.string().min(16),
},
// ─── Documenso signers ────────────────────────────────────────────────────
{
key: 'documenso_developer_name',
section: 'documenso.signers',
label: 'Developer signer — name',
description:
"Override the name on the developer recipient slot. Leave blank to use whatever's set on the Documenso template.",
type: 'string',
scope: 'port',
},
{
key: 'documenso_developer_email',
section: 'documenso.signers',
label: 'Developer signer — email',
description:
"Override the email on the developer recipient slot. Leave blank to use whatever's set on the Documenso template.",
type: 'email',
scope: 'port',
},
{
key: 'documenso_developer_label',
section: 'documenso.signers',
label: 'Developer signer — label',
description: 'Display label shown on the signing screen (defaults to "Developer").',
type: 'string',
scope: 'port',
placeholder: 'Developer',
},
{
key: 'documenso_developer_recipient_id',
section: 'documenso.signers',
label: 'Developer Documenso recipient ID',
description:
'Numeric Documenso recipient slot ID for the developer signer. Set automatically by "Sync from Documenso" — you rarely set this by hand.',
type: 'number',
scope: 'port',
envFallback: 'DOCUMENSO_DEVELOPER_RECIPIENT_ID',
},
{
key: 'documenso_developer_user_id',
section: 'documenso.signers',
label: 'Developer signer — linked CRM user (optional)',
description:
"Project Director RBAC binding. When set, the webhook handler fires an in-CRM notification for this user when it's their turn to sign — alongside the branded email. Leave blank if the developer slot doesn't map to a CRM user (e.g. external developer).",
type: 'user-select',
scope: 'port',
},
{
key: 'documenso_approver_name',
section: 'documenso.signers',
label: 'Approver signer — name',
description:
"Override the name on the approver recipient slot. Leave blank to use whatever's set on the Documenso template.",
type: 'string',
scope: 'port',
},
{
key: 'documenso_approver_email',
section: 'documenso.signers',
label: 'Approver signer — email',
description:
"Override the email on the approver recipient slot. Leave blank to use whatever's set on the Documenso template.",
type: 'email',
scope: 'port',
},
{
key: 'documenso_approver_label',
section: 'documenso.signers',
label: 'Approver signer — label',
description: 'Display label shown on the signing screen (defaults to "Approver").',
type: 'string',
scope: 'port',
placeholder: 'Approver',
},
{
key: 'documenso_approval_recipient_id',
section: 'documenso.signers',
label: 'Approver Documenso recipient ID',
description:
'Numeric Documenso recipient slot ID for the approver. Set automatically by "Sync from Documenso" — you rarely set this by hand.',
type: 'number',
scope: 'port',
envFallback: 'DOCUMENSO_APPROVAL_RECIPIENT_ID',
},
{
key: 'documenso_approver_user_id',
section: 'documenso.signers',
label: 'Approver — linked CRM user (optional)',
description:
"Same as developer's linked user — when set, fires an in-CRM notification when it's the approver's turn to sign.",
type: 'user-select',
scope: 'port',
},
{
key: 'documenso_client_recipient_id',
section: 'documenso.signers',
label: 'Client recipient ID',
description:
'Documenso recipient ID for the client slot. Maps to DOCUMENSO_CLIENT_RECIPIENT_ID in env.',
type: 'number',
scope: 'port',
envFallback: 'DOCUMENSO_CLIENT_RECIPIENT_ID',
},
// ─── Documenso templates ──────────────────────────────────────────────────
{
key: 'documenso_eoi_template_id',
section: 'documenso.templates',
label: 'EOI Documenso template ID',
description:
'Numeric template ID used by the Documenso EOI pathway. Populated automatically by "Sync from Documenso" below.',
type: 'number',
scope: 'port',
envFallback: 'DOCUMENSO_TEMPLATE_ID_EOI',
placeholder: '12345',
},
{
key: 'eoi_default_pathway',
section: 'documenso.templates',
label: 'Default EOI pathway',
description:
'Which pathway is used when an EOI is generated without an explicit choice. Documenso = signed via Documenso, In-app = filled locally with pdf-lib.',
type: 'select',
options: [
{ value: 'documenso-template', label: 'Documenso template' },
{ value: 'inapp', label: 'In-app (pdf-lib)' },
],
scope: 'port',
defaultValue: 'documenso-template',
},
{
key: 'eoi_send_mode',
section: 'documenso.templates',
label: 'Initial signing-invitation email behaviour',
description:
'Auto = the system sends the branded "please sign" email immediately when an EOI/contract/reservation is generated. Manual = the document is generated and the signing URL appears in the UI; a rep clicks "Send invitation" to dispatch. Applies to all document types, not just EOI.',
type: 'select',
options: [
{ value: 'manual', label: 'Manual (rep clicks Send after generation)' },
{ value: 'auto', label: 'Auto (send branded email on generate)' },
],
scope: 'port',
defaultValue: 'manual',
},
{
key: 'documenso_reservation_template_id',
section: 'documenso.templates',
label: 'Reservation template ID',
description: 'Template ID used for reservation agreements (optional).',
type: 'number',
scope: 'port',
},
{
key: 'documenso_contract_template_id',
section: 'documenso.templates',
label: 'Contract template ID',
description: 'Template ID used for the final purchase / lease contract (optional).',
type: 'number',
scope: 'port',
},
// ─── Documenso behavior ───────────────────────────────────────────────────
{
key: 'documenso_signing_order',
section: 'documenso.behavior',
label: 'Signing order',
description:
'PARALLEL = all recipients can sign at once. SEQUENTIAL = each waits for the previous (v2 only — v1 always parallel).',
type: 'select',
options: [
{ value: 'PARALLEL', label: 'Parallel — all recipients sign concurrently' },
{ value: 'SEQUENTIAL', label: 'Sequential — order matters (v2 only)' },
],
scope: 'port',
defaultValue: 'PARALLEL',
},
{
key: 'documenso_redirect_url',
section: 'documenso.behavior',
label: 'Post-sign redirect URL',
description: 'Where signers land after completing their signature. Both v1 and v2 honour it.',
type: 'url',
scope: 'port',
},
// ─── Pipeline auto-advance ───────────────────────────────────────────────
// JSON map keyed by trigger name; value is one of 'auto' | 'suggest' |
// 'off'. Read by `getStageAdvanceMode` in port-config.ts. The registry
// entry uses the generic `string` type because the form generator's
// schemas don't have a JSON variant — the admin UI is a dedicated page
// (/admin/pipeline-rules) that renders 3-way toggles per trigger.
{
key: 'stage_advance_rules',
section: 'pipeline.auto_advance',
label: 'Pipeline auto-advance rules',
description:
'Per-trigger control for whether lifecycle events (EOI sent/signed, deposit received, etc.) auto-advance the deal stage, only suggest the move via a notification, or do nothing.',
type: 'string',
scope: 'port',
validator: z.record(z.string(), z.enum(['auto', 'suggest', 'off'])),
},
// ─── AI / OpenAI ──────────────────────────────────────────────────────────
{
key: 'ai_enabled',
section: 'ai.master',
label: 'AI features enabled',
description:
'Master switch. When OFF, every AI surface (receipt OCR, berth-PDF AI parse) is bypassed. Provider keys stay configured but unused.',
type: 'boolean',
scope: 'port',
defaultValue: true,
},
{
key: 'ai_monthly_token_cap',
section: 'ai.master',
label: 'Monthly token cap (this port)',
description:
'Soft cap on total AI tokens consumed per calendar month. When exceeded, AI features fall back to non-AI paths and surface a banner. Set 0 for no cap.',
type: 'number',
scope: 'port',
defaultValue: 0,
},
{
key: 'openai_api_key',
section: 'ai.providers',
label: 'OpenAI API key',
description: 'Used by Receipt OCR fallback and berth-PDF AI parse. AES-encrypted at rest.',
type: 'password',
scope: 'port',
encrypted: true,
sensitive: true,
envFallback: 'OPENAI_API_KEY',
placeholder: 'sk-…',
},
{
key: 'openai_default_model',
section: 'ai.providers',
label: 'Default OpenAI model',
description: 'Used when a feature does not specify an explicit model.',
type: 'select',
options: [
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini — cheap, fast, vision-capable' },
{ value: 'gpt-4o', label: 'gpt-4o — full-strength multimodal' },
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo — legacy text reasoning' },
],
scope: 'port',
defaultValue: 'gpt-4o-mini',
},
// ─── Email — From / Reply-To ──────────────────────────────────────────────
{
key: 'email_from_name',
section: 'email.from',
label: 'From name',
description: 'Display name shown in the From: header on outgoing email.',
type: 'string',
scope: 'port',
placeholder: 'Port Nimara',
},
{
key: 'email_from_address',
section: 'email.from',
label: 'From address',
description: 'Sender email address. Falls back to SMTP_FROM env when blank.',
type: 'email',
scope: 'port',
envFallback: 'SMTP_FROM',
placeholder: 'noreply@example.com',
},
{
key: 'email_reply_to',
section: 'email.from',
label: 'Reply-to address',
description: 'Optional Reply-To: header for replies (e.g. sales@example.com).',
type: 'email',
scope: 'port',
placeholder: 'sales@example.com',
},
// ─── Email — SMTP overrides ───────────────────────────────────────────────
{
key: 'smtp_host_override',
section: 'email.smtp',
label: 'SMTP host override',
description: 'Falls back to SMTP_HOST env when blank.',
type: 'string',
scope: 'port',
envFallback: 'SMTP_HOST',
placeholder: 'mail.example.com',
},
{
key: 'smtp_port_override',
section: 'email.smtp',
label: 'SMTP port override',
description: 'Falls back to SMTP_PORT env when blank.',
type: 'number',
scope: 'port',
envFallback: 'SMTP_PORT',
placeholder: '587',
},
{
key: 'smtp_user_override',
section: 'email.smtp',
label: 'SMTP user override',
description: 'Falls back to SMTP_USER env when blank.',
type: 'string',
scope: 'port',
envFallback: 'SMTP_USER',
},
{
key: 'smtp_pass_override',
section: 'email.smtp',
label: 'SMTP password override',
description: 'AES-encrypted at rest. Falls back to SMTP_PASS env when blank.',
type: 'password',
scope: 'port',
encrypted: true,
sensitive: true,
envFallback: 'SMTP_PASS',
},
// ─── Storage — S3 / MinIO ─────────────────────────────────────────────────
{
key: 'storage_s3_endpoint',
section: 'storage.s3',
label: 'S3 endpoint URL',
description:
'Full URL including scheme and port (e.g. https://s3.amazonaws.com or http://localhost:9000 for MinIO).',
type: 'url',
scope: 'global',
envFallback: 'MINIO_ENDPOINT',
},
{
key: 'storage_s3_region',
section: 'storage.s3',
label: 'S3 region',
description: 'AWS region or "auto" for many S3-compatible providers.',
type: 'string',
scope: 'global',
defaultValue: 'us-east-1',
},
{
key: 'storage_s3_bucket',
section: 'storage.s3',
label: 'S3 bucket name',
description: 'The bucket to read/write file content.',
type: 'string',
scope: 'global',
envFallback: 'MINIO_BUCKET',
placeholder: 'crm-files',
},
{
// Stored under the new `_encrypted` suffix to mirror the existing
// `storage_s3_secret_key_encrypted` convention. The migration script
// moves the legacy plaintext row at `storage_s3_access_key` into this
// key (fixes audit finding S-23).
key: 'storage_s3_access_key_encrypted',
section: 'storage.s3',
label: 'S3 access key',
description:
'IAM access key id. AES-encrypted at rest (was previously stored plaintext — fixed in this migration).',
type: 'password',
scope: 'global',
encrypted: true,
sensitive: true,
envFallback: 'MINIO_ACCESS_KEY',
},
{
key: 'storage_s3_secret_key_encrypted',
section: 'storage.s3',
label: 'S3 secret key',
description: 'IAM secret access key. AES-encrypted at rest.',
type: 'password',
scope: 'global',
encrypted: true,
sensitive: true,
envFallback: 'MINIO_SECRET_KEY',
},
{
key: 'storage_s3_force_path_style',
section: 'storage.s3',
label: 'Force path-style URLs',
description:
'On for MinIO and most self-hosted S3-compatible servers. Off for AWS S3 (which uses virtual-hosted-style by default).',
type: 'boolean',
scope: 'global',
defaultValue: false,
},
// ─── Storage — Filesystem (single-node only) ──────────────────────────────
{
key: 'storage_filesystem_root',
section: 'storage.filesystem',
label: 'Filesystem root path',
description:
'Absolute directory where files land when the active backend is filesystem. Single-node deployments only — multi-node MUST use S3.',
type: 'string',
scope: 'global',
placeholder: '/var/lib/pn-crm/files',
},
// ─── App URLs ─────────────────────────────────────────────────────────────
{
key: 'app_url',
section: 'app.urls',
label: 'App URL (this CRM)',
description:
'Public URL of this CRM instance. Used in outbound emails and webhook URL construction.',
type: 'url',
scope: 'global',
envFallback: 'APP_URL',
placeholder: 'https://crm.example.com',
},
{
key: 'public_site_url',
section: 'app.urls',
label: 'Marketing site URL',
description: 'The public marketing website URL. Used by some templates and CTAs.',
type: 'url',
scope: 'global',
envFallback: 'PUBLIC_SITE_URL',
placeholder: 'https://example.com',
},
];
/** Quick lookup index keyed by setting key. */
const REGISTRY_INDEX = new Map<string, SettingEntry>(REGISTRY.map((e) => [e.key, e]));
export function registryFor(key: string): SettingEntry | undefined {
return REGISTRY_INDEX.get(key);
}
export function entriesForSection(section: string): SettingEntry[] {
return REGISTRY.filter((e) => e.section === section);
}
export function entriesForSections(sections: string[]): SettingEntry[] {
const set = new Set(sections);
return REGISTRY.filter((e) => set.has(e.section));
}

View File

@@ -0,0 +1,362 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { decrypt, encrypt } from '@/lib/utils/encryption';
import { registryFor } from './registry';
import type { ResolvedSetting, SettingEntry, SettingSource } from './types';
/**
* Stored shape for encrypted JSONB values. The encrypt() helper returns a
* JSON string of this shape — we wrap it in the JSONB column verbatim.
*/
interface EncryptedEnvelope {
iv: string;
tag: string;
data: string;
}
function isEncryptedEnvelope(value: unknown): value is EncryptedEnvelope {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { iv?: unknown }).iv === 'string' &&
typeof (value as { tag?: unknown }).tag === 'string' &&
typeof (value as { data?: unknown }).data === 'string'
);
}
/**
* Validator inferred from the entry type when no explicit `validator` is set.
* Keeps the registry concise — only override when standard rules don't fit.
*/
function defaultValidator(entry: SettingEntry): z.ZodTypeAny {
if (entry.validator) return entry.validator;
switch (entry.type) {
case 'string':
case 'password':
case 'textarea':
case 'user-select':
return z.string();
case 'url':
return z.string().url();
case 'email':
return z.string().email();
case 'number':
return z.coerce.number();
case 'boolean':
return z.coerce.boolean();
case 'select':
if (entry.options) {
return z.enum(entry.options.map((o) => o.value) as [string, ...string[]]);
}
return z.string();
default:
return z.unknown();
}
}
function coerceForType(entry: SettingEntry, raw: unknown): unknown {
if (raw == null) return null;
if (entry.transform) return entry.transform(raw);
if (entry.type === 'number') {
const n = typeof raw === 'number' ? raw : Number(raw);
return Number.isFinite(n) ? n : null;
}
if (entry.type === 'boolean') {
if (typeof raw === 'boolean') return raw;
if (raw === 'true' || raw === '1') return true;
if (raw === 'false' || raw === '0') return false;
return Boolean(raw);
}
return raw;
}
function readEnvValue(entry: SettingEntry): unknown | null {
if (!entry.envFallback) return null;
const v = process.env[entry.envFallback];
if (v == null || v === '') return null;
return coerceForType(entry, v);
}
function unwrapStoredValue(entry: SettingEntry, stored: unknown): unknown {
if (stored == null) return null;
if (entry.encrypted && isEncryptedEnvelope(stored)) {
return decrypt(JSON.stringify(stored));
}
// Settings written via the legacy upsertSetting helper wrap the value in
// `{ value: ... }`. Unwrap that shape transparently for backward compat.
if (
typeof stored === 'object' &&
stored !== null &&
'value' in stored &&
Object.keys(stored as object).length === 1
) {
return (stored as { value: unknown }).value;
}
return stored;
}
interface ResolvedRaw {
source: SettingSource;
rawValue: unknown;
}
/**
* Lower-level lookup that returns both the resolved value AND the source it
* came from (port row, global row, env, or registry default). The admin API
* uses this directly to drive the "Using env fallback" badge; service code
* usually calls `getSetting()` which discards the source.
*/
export async function resolveSettingWithSource(
key: string,
portId: string | null,
): Promise<ResolvedRaw> {
const entry = registryFor(key);
if (!entry) throw new Error(`Unknown setting key: ${key}`);
// 1. Port-specific row (only meaningful for port-scoped entries).
if (portId && entry.scope === 'port') {
const row = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
});
if (row?.value != null) {
return { source: 'port', rawValue: unwrapStoredValue(entry, row.value) };
}
}
// 2. Global row (port_id IS NULL).
const globalRow = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)),
});
if (globalRow?.value != null) {
return { source: 'global', rawValue: unwrapStoredValue(entry, globalRow.value) };
}
// 3. Env fallback.
const envValue = readEnvValue(entry);
if (envValue != null) {
return { source: 'env', rawValue: envValue };
}
// 4. Registry default.
return { source: 'default', rawValue: entry.defaultValue ?? null };
}
/**
* Resolves a setting value through the precedence chain: port → global → env
* → registry default. Encrypted values are decrypted on the way out.
*
* Use this from service code that needs the concrete cleartext value
* (e.g. building an outbound Documenso request).
*/
export async function getSetting<T = unknown>(
key: string,
portId: string | null,
): Promise<T | null> {
const { rawValue } = await resolveSettingWithSource(key, portId);
return rawValue as T | null;
}
/**
* Batch resolver — efficient for the admin form which needs every field in a
* section. Returns a map keyed by setting key.
*/
export async function resolveSettings(
keys: string[],
portId: string | null,
): Promise<Map<string, ResolvedRaw>> {
const out = new Map<string, ResolvedRaw>();
await Promise.all(
keys.map(async (k) => {
out.set(k, await resolveSettingWithSource(k, portId));
}),
);
return out;
}
/**
* Shape returned to the admin API. Sensitive fields surface `isSet` only.
*/
export async function resolveForAdminAPI(
keys: string[],
portId: string | null,
): Promise<Map<string, ResolvedSetting>> {
const resolved = await resolveSettings(keys, portId);
const out = new Map<string, ResolvedSetting>();
for (const key of keys) {
const entry = registryFor(key);
if (!entry) continue;
const r = resolved.get(key);
if (!r) continue;
const isSet = r.source !== 'default' && r.rawValue != null && r.rawValue !== '';
const surfaceSensitive = entry.sensitive || entry.encrypted;
out.set(key, {
key,
source: r.source,
isSet,
value: surfaceSensitive ? undefined : (r.rawValue ?? undefined),
});
}
return out;
}
/**
* Validate and persist a setting. Encrypts if registered as encrypted. Always
* writes to the row scope appropriate to the entry: port-scoped entries with
* a non-null portId write the port row; global-scoped entries (or when called
* with portId=null) write the global row.
*/
export async function writeSetting(
key: string,
rawValue: unknown,
portId: string | null,
meta: AuditMeta,
): Promise<void> {
const entry = registryFor(key);
if (!entry) throw new ValidationError(`Unknown setting: ${key}`);
// Empty value on a settable field == delete the row (revert to fallback).
// Sensitive/encrypted: empty input means "don't change" rather than
// "revert" — UI shows ••• placeholder so an unchanged save shouldn't
// wipe the stored ciphertext. The dedicated DELETE endpoint exists for
// explicit reverts.
if (rawValue === '' || rawValue == null) {
if (entry.encrypted || entry.sensitive) {
// No-op: leaving the existing row untouched.
return;
}
await deleteSetting(key, portId, meta);
return;
}
const validator = defaultValidator(entry);
const parsed = validator.safeParse(rawValue);
if (!parsed.success) {
throw new ValidationError(
`Invalid value for "${key}": ${parsed.error.issues
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('; ')}`,
);
}
const value = parsed.data;
const writePortId = entry.scope === 'global' ? null : portId;
const storedValue = entry.encrypted
? (JSON.parse(encrypt(String(value))) as EncryptedEnvelope)
: value;
// Read existing for audit diff.
const existing = writePortId
? await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, writePortId)),
})
: await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)),
});
await db
.insert(systemSettings)
.values({
key,
value: storedValue as Record<string, unknown>,
portId: writePortId,
updatedBy: meta.userId,
})
.onConflictDoUpdate({
target: [systemSettings.key, systemSettings.portId],
set: {
value: storedValue as Record<string, unknown>,
updatedBy: meta.userId,
updatedAt: new Date(),
},
});
// Audit-log with redaction for sensitive / encrypted fields — fixes AU-02
// (encrypted ciphertext stored in audit_logs.new_value).
const isSecret = entry.encrypted || entry.sensitive;
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
action: existing ? 'update' : 'create',
entityType: 'setting',
entityId: key,
oldValue: existing ? { value: isSecret ? '[redacted]' : existing.value } : undefined,
newValue: { value: isSecret ? '[redacted]' : value },
metadata: { settingKey: key, scope: entry.scope },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
/**
* Delete a setting row, reverting the resolver to global → env → default.
* No-op (with NotFoundError) if no row exists at the target scope.
*/
export async function deleteSetting(
key: string,
portId: string | null,
meta: AuditMeta,
): Promise<void> {
const entry = registryFor(key);
if (!entry) throw new ValidationError(`Unknown setting: ${key}`);
const writePortId = entry.scope === 'global' ? null : portId;
const existing = writePortId
? await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, writePortId)),
})
: await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)),
});
if (!existing) throw new NotFoundError('Setting');
await db
.delete(systemSettings)
.where(
writePortId
? and(eq(systemSettings.key, key), eq(systemSettings.portId, writePortId))
: and(eq(systemSettings.key, key), isNull(systemSettings.portId)),
);
const isSecret = entry.encrypted || entry.sensitive;
void createAuditLog({
userId: meta.userId,
portId: meta.portId,
action: 'delete',
entityType: 'setting',
entityId: key,
oldValue: { value: isSecret ? '[redacted]' : existing.value },
metadata: { settingKey: key, scope: entry.scope },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
/**
* One-click migration: read the env var named in `entry.envFallback`, write
* it as the current scope's row. Used by the admin UI "Copy from env" button.
*/
export async function copyFromEnv(
key: string,
portId: string | null,
meta: AuditMeta,
): Promise<{ copied: boolean; envValue?: string }> {
const entry = registryFor(key);
if (!entry) throw new ValidationError(`Unknown setting: ${key}`);
if (!entry.envFallback) {
throw new ValidationError(`Setting "${key}" has no env fallback configured`);
}
const envValue = process.env[entry.envFallback];
if (envValue == null || envValue === '') {
return { copied: false };
}
await writeSetting(key, envValue, portId, meta);
return { copied: true, envValue: entry.encrypted || entry.sensitive ? undefined : envValue };
}

77
src/lib/settings/types.ts Normal file
View File

@@ -0,0 +1,77 @@
import { z } from 'zod';
export type SettingType =
| 'string'
| 'password'
| 'number'
| 'boolean'
| 'select'
| 'url'
| 'email'
| 'textarea'
/**
* Renders a dropdown of the current port's CRM users (email + name).
* Stored as the user's UUID. Used for fields that bind a CRM user to a
* Documenso recipient slot for in-CRM notification routing.
*/
| 'user-select';
export type SettingScope = 'port' | 'global';
export type SettingSource = 'port' | 'global' | 'env' | 'default';
export interface SettingOption {
value: string;
label: string;
}
export interface SettingEntry {
/** Stable key written to system_settings.key. Use snake_case. */
key: string;
/** Grouping name used by the admin form generator (e.g. "documenso.api"). */
section: string;
/** UI label. */
label: string;
/** UI description (plain text — can include backticks for inline code). */
description: string;
/** Drives both validation and form input rendering. */
type: SettingType;
/** select-only — list of option choices. */
options?: SettingOption[];
/** Optional Zod schema. When omitted, a type-appropriate default is used. */
validator?: z.ZodTypeAny;
/** Default applied when port + global + env all absent. */
defaultValue?: string | number | boolean | null;
/** Encrypt at rest with AES-256-GCM. Implies sensitive. */
encrypted?: boolean;
/** Per-port (with global + env fallback) or global-only (super-admin). */
scope: SettingScope;
/** Env var name consulted when port + global blank. */
envFallback?: string;
/** Value transformer applied after resolution (e.g. coerce string→number). */
transform?: (raw: unknown) => unknown;
/**
* Sensitive: never surface cleartext via admin API. Encrypted fields are
* sensitive by default; non-encrypted fields may still be marked sensitive
* (e.g. an externally-shared secret) — the API emits `<key>IsSet: boolean`.
*/
sensitive?: boolean;
/** Placeholder text shown in the input. */
placeholder?: string;
}
/**
* Resolved-value response shape returned to the admin UI.
*
* - `source` tells the UI whether the value came from the port row, the
* global row, the env fallback, or the registry default — which drives
* the "Using env fallback" badge.
* - `value` carries the resolved cleartext for non-sensitive fields. For
* sensitive / encrypted fields it is omitted and only `isSet` is exposed.
*/
export interface ResolvedSetting<T = unknown> {
key: string;
source: SettingSource;
isSet: boolean;
value?: T;
}

View File

@@ -128,12 +128,17 @@ async function readGlobalSetting<T = unknown>(key: string): Promise<T | null> {
async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
// Each setting key is a separate row. We read them in parallel.
// `storage_s3_access_key_encrypted` is the modern home for the S3 access
// key (fixes audit S-23 — was previously stored plaintext at
// `storage_s3_access_key`). We still read the legacy plaintext key for
// backward compat, but the encrypted form wins when both are present.
const keys = [
'storage_backend',
'storage_s3_endpoint',
'storage_s3_region',
'storage_s3_bucket',
'storage_s3_access_key',
'storage_s3_access_key', // legacy plaintext (kept for backward compat)
'storage_s3_access_key_encrypted', // modern AES envelope
'storage_s3_secret_key_encrypted',
'storage_s3_force_path_style',
'storage_filesystem_root',
@@ -144,7 +149,8 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
s3Endpoint,
s3Region,
s3Bucket,
s3AccessKey,
s3AccessKeyLegacy,
s3AccessKeyEncryptedRaw,
s3SecretKeyEncrypted,
s3ForcePathStyle,
fsRoot,
@@ -153,13 +159,32 @@ async function loadStorageConfig(): Promise<StorageConfigSnapshot> {
const backend: StorageBackendName = backendRaw === 'filesystem' ? 'filesystem' : 's3';
// Prefer the encrypted form. Decrypt inline so downstream `S3Backend.create`
// still receives the cleartext under the existing `accessKey` field.
let accessKey: string | undefined;
if (s3AccessKeyEncryptedRaw && typeof s3AccessKeyEncryptedRaw === 'object') {
const env = s3AccessKeyEncryptedRaw as { iv?: string; tag?: string; data?: string };
if (env.iv && env.tag && env.data) {
try {
accessKey = (await import('@/lib/utils/encryption')).decrypt(
JSON.stringify(s3AccessKeyEncryptedRaw),
);
} catch (err) {
logger.error({ err }, 'Failed to decrypt storage_s3_access_key_encrypted');
}
}
}
if (!accessKey && typeof s3AccessKeyLegacy === 'string') {
accessKey = s3AccessKeyLegacy;
}
return {
backend,
s3: {
endpoint: typeof s3Endpoint === 'string' ? s3Endpoint : undefined,
region: typeof s3Region === 'string' ? s3Region : undefined,
bucket: typeof s3Bucket === 'string' ? s3Bucket : undefined,
accessKey: typeof s3AccessKey === 'string' ? s3AccessKey : undefined,
accessKey,
secretKeyEncrypted:
typeof s3SecretKeyEncrypted === 'string' ? s3SecretKeyEncrypted : undefined,
forcePathStyle:

View File

@@ -102,8 +102,16 @@ function parseEndpoint(endpoint: string | undefined): {
port: number;
useSSL: boolean;
} {
// MINIO_* env vars are now optional (admin settings are canonical).
// Fall back to localhost defaults so the parser still returns a complete
// shape; the storage backend will fail-fast on the actual connect attempt
// when both env + admin settings are unset.
if (!endpoint) {
return { endPoint: env.MINIO_ENDPOINT, port: env.MINIO_PORT, useSSL: env.MINIO_USE_SSL };
return {
endPoint: env.MINIO_ENDPOINT ?? 'localhost',
port: env.MINIO_PORT ?? 9000,
useSSL: env.MINIO_USE_SSL ?? false,
};
}
try {
const url = new URL(endpoint);
@@ -112,7 +120,11 @@ function parseEndpoint(endpoint: string | undefined): {
return { endPoint: url.hostname, port, useSSL };
} catch {
// Not a URL — treat as bare hostname and use defaults.
return { endPoint: endpoint, port: env.MINIO_PORT, useSSL: env.MINIO_USE_SSL };
return {
endPoint: endpoint,
port: env.MINIO_PORT ?? 9000,
useSSL: env.MINIO_USE_SSL ?? false,
};
}
}
@@ -123,9 +135,9 @@ function resolveConfig(cfg: S3BackendConfig): ResolvedConfig {
port: ep.port,
useSSL: ep.useSSL,
region: cfg.region ?? 'us-east-1',
bucket: cfg.bucket ?? env.MINIO_BUCKET,
accessKey: cfg.accessKey ?? env.MINIO_ACCESS_KEY,
secretKey: decryptIfPresent(cfg.secretKeyEncrypted) ?? env.MINIO_SECRET_KEY,
bucket: cfg.bucket ?? env.MINIO_BUCKET ?? '',
accessKey: cfg.accessKey ?? env.MINIO_ACCESS_KEY ?? '',
secretKey: decryptIfPresent(cfg.secretKeyEncrypted) ?? env.MINIO_SECRET_KEY ?? '',
};
}

View File

@@ -76,6 +76,10 @@ export const generateAndSignSchema = generateSchema.extend({
)
.optional()
.default([]),
/** Which unit (ft|m) of the yacht's stored dimensions flows into the
* EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
* server defaults to the yacht's `lengthUnit` column when omitted. */
dimensionUnit: z.enum(['ft', 'm']).optional(),
});
export type CreateTemplateInput = z.infer<typeof createTemplateSchema>;

View File

@@ -87,8 +87,14 @@ export const createInterestSchema = z.object({
// ─── Update ──────────────────────────────────────────────────────────────────
// C-03: pipelineStage MUST flow through changeInterestStage / the /stage
// endpoint, which enforces canTransitionStage + override-permission +
// override-reason. Omitting it from the generic update schema closes the
// bypass surface where a PATCH /interests/[id] could drive an interest to
// any stage with no guards, no audit-as-stage-change, and no override
// reason.
export const updateInterestSchema = createInterestSchema
.omit({ clientId: true, tagIds: true })
.omit({ clientId: true, tagIds: true, pipelineStage: true })
.partial();
// ─── Change Stage ─────────────────────────────────────────────────────────────