Files
pn-new-crm/src/lib/settings/registry.ts
Matt 989cc4d72b feat(uat-batch): Group I — Residential parity (4 ships)
I34–I37 from the 2026-05-21 plan.

Shipped:
  I34  Residential client header layout parity. Email / Call /
       WhatsApp action buttons mirror the main ClientDetailHeader.
       WhatsApp number resolves from phoneE164 (preferred) or strips
       the free-text phone to digits. Header surfaces "Linked to
       main client" chip when the auto-link matcher (I37) finds a
       counterpart in the main CRM.
  I35  Residential interests list rebuilt for parity with the main
       InterestList. New ResidentialInterestCard +
       getResidentialInterestColumns + residentialInterestFilter-
       Definitions; the list page drives DataTable + FilterBar +
       ColumnPicker + SavedViewsDropdown + bulkActions. List
       endpoint validator widened to accept pipelineStage as a
       string OR string[] and added a source filter. Service post-
       fetches client names via a single IN-list lookup so the
       table renders fullName in column 1 without N+1.
       New /api/v1/residential/interests/bulk supports
       change_stage + archive (100-id cap). Kanban view deferred.
  I36  Residential inquiries auto-forward to partner email(s).
       New registry entry residential_partner_recipients (comma-
       separated) under section residential.partner.
       createResidentialInterest fires
       forwardResidentialInquiryToPartner after the row lands.
       Helper uses the same branded shell other transactional
       emails use. Failures log + never block create. The
       /admin/residential-stages page picks up a registry-driven
       card so admins manage recipients alongside stages.
  I37  Auto-link residential ↔ main client. Migration 0080 adds
       residential_clients.linked_client_id (nullable FK, SET NULL
       on cascade) + partial index. New findAndLinkMatchingMainClient
       service matches by email first (case-insensitive client_contacts
       lookup) then by E.164 phone. First exact match wins. Fires
       fire-and-forget from createResidentialClient. Header surfaces
       the link via a "Linked to main client" chip. Backfill script
       + reverse-direction link from main ClientDetailHeader stay
       as follow-ups.

Verified: tsc clean, vitest 1454/1454, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:57:19 +02:00

631 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
},
{
key: 'supplemental_form_url',
section: 'email.from',
label: 'Supplemental info form URL (optional)',
description:
"When set, the supplemental-info email links to this URL with ?token=… appended (typically the marketing site's hosted form). Leave blank to use the built-in CRM form at /public/supplemental-info/<token>. Useful when you want the client to land on a branded marketing-site page instead of the CRM domain.",
type: 'string',
scope: 'port',
placeholder: 'https://portnimara.com/supplemental',
},
// ─── 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',
},
// ─── Deal Pulse (Phase 2) ─────────────────────────────────────────────────
// Per-port admin controls for the deal-pulse chip on interest lists +
// detail headers. Master toggle hides the chip entirely; per-signal
// toggles let admins quiet specific signal types; label overrides
// rename tier labels for ports that prefer their own vocabulary.
{
key: 'pulse_enabled',
section: 'pulse',
label: 'Show deal pulse chips',
description:
'Master toggle. When off, the pulse chip is hidden on every interest list row + detail header for this port. Useful when a port prefers to triage pipelines without the AI-tinted chip.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_eoi_sent_recent_enabled',
section: 'pulse',
label: 'Signal: recent EOI sent (positive)',
description: 'Default on. Brightens chip when EOI was sent in last 14 days.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_deposit_received_enabled',
section: 'pulse',
label: 'Signal: deposit received (positive)',
description: 'Default on. Strong forward signal once a deposit invoice flips to paid.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_contract_signed_enabled',
section: 'pulse',
label: 'Signal: contract signed (positive)',
description: 'Default on. Reinforces closed-loop progress until outcome flips to won.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_document_declined_enabled',
section: 'pulse',
label: 'Signal: document declined (risk)',
description:
'Default on. Strongest cooling signal — client refused to sign an EOI / contract / reservation. Requires the risk-data wiring shipped alongside Phase 2 to populate.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_reservation_cancelled_enabled',
section: 'pulse',
label: 'Signal: reservation cancelled (risk)',
description: 'Default on. Booked-then-cancelled signals require rep attention.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_signal_berth_sold_to_other_enabled',
section: 'pulse',
label: 'Signal: berth resold (risk)',
description: 'Default on. Primary berth got linked to a different completed interest.',
type: 'boolean',
scope: 'port',
},
{
key: 'pulse_label_hot',
section: 'pulse',
label: 'Custom label: Hot tier',
description: 'Leave blank to use the built-in "Hot" label.',
type: 'string',
scope: 'port',
placeholder: 'Hot',
},
{
key: 'pulse_label_warm',
section: 'pulse',
label: 'Custom label: Warm tier',
description: 'Leave blank to use the built-in "Warm" label.',
type: 'string',
scope: 'port',
placeholder: 'Warm',
},
{
key: 'pulse_label_cold',
section: 'pulse',
label: 'Custom label: Cold tier',
description: 'Leave blank to use the built-in "Cold" label.',
type: 'string',
scope: 'port',
placeholder: 'Cold',
},
// ─── Residential — partner forwarding ──────────────────────────────────────
{
key: 'residential_partner_recipients',
section: 'residential.partner',
label: 'Partner forwarding recipients',
description:
'Comma-separated list of email addresses that receive a copy of every new residential inquiry the moment it lands. Leave blank to disable partner forwarding. Reps still see every inquiry in the CRM; this is an outbound courtesy notification for an external partner who handles residential leads on the ports behalf.',
type: 'string',
scope: 'port',
placeholder: 'partner@example.com, partner2@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));
}