Files
pn-new-crm/src/lib/settings/registry.ts
Matt cb8292464c feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers
Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.

UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
  sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
  aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
  Aggregated helpers in notes.service mirror the listFor*Aggregated
  symmetric-reach joins. yacht-tabs + company-tabs render the
  badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
  `width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
  fixed inset-0 pin so long forms scroll naturally). Form picks up
  port branding (logoUrl + backgroundUrl + appName) via
  loadByToken. Address fields completed (street + city + region +
  postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
  emits toast.success with action link to the destination entity
  or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
  rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
  (5 types visible at once).

Launch infra:
- UTM column wiring (Init 1b step 4): migration
  0089_website_submissions_utm.sql adds utm_source/medium/campaign/
  term/content + composite index (port_id, utm_source, received_at)
  for per-campaign rollups. website-inquiries intake accepts the
  five fields. Residential intake intentionally untouched per audit
  scope.
- Invoicing module gate (Init 1c spike): new
  invoices-module.service + invoices layout guard + registry entry
  invoices_module_enabled (default false). Audit conclusion in
  launch-readiness.md: payments table is canonical money path;
  /invoices flow is parallel infrastructure now hidden by default.

Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
  New route-labels.ts + use-smart-back hook +
  navigation-history-tracker so back falls through to the parent
  route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
  breadcrumb-store kept for back-compat consumers but the
  breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
  upload-receipts, reports kind, tenancies detail, analytics
  metric, client detail) migrated.

Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
  the reports gap audit (cross-cutting filter set, Marketing +
  Financial blockers, custom builder remaining entities, scheduled
  CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
  OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
  (each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.

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

693 lines
26 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: 'radio',
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:
'Default flow when this port creates an envelope. Leave on "Use template default" to honour each Documenso template\'s own setting; pick PARALLEL or SEQUENTIAL to override every envelope this port creates (v2 only - v1 is always parallel).',
type: 'radio',
options: [
{
value: 'TEMPLATE_DEFAULT',
label: 'Use template default (recommended)',
},
{ value: 'PARALLEL', label: 'Parallel - all recipients sign concurrently' },
{ value: 'SEQUENTIAL', label: 'Sequential - order matters (v2 only)' },
],
scope: 'port',
defaultValue: 'TEMPLATE_DEFAULT',
},
{
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',
},
// ─── Operations - Tenancies module ────────────────────────────────────────
// Platform-wide gate for the Tenancies (occupancy-record) surface area.
// Disabled by default. A first row INSERT on the `tenancies` table flips
// this on automatically (`pg_advisory_xact_lock` per port keeps the flip
// race-safe). Admins can also enable explicitly from Admin -> Operations,
// and disabling with existing rows is a soft hide (data is preserved but
// invisible until re-enabled).
{
key: 'tenancies_module_enabled',
section: 'operations.tenancies',
label: 'Tenancies module',
description:
'When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform does not model the occupancy record. Auto-enables on the first tenancy created (e.g. via a signed Reservation Agreement).',
type: 'boolean',
scope: 'port',
defaultValue: false,
},
// ─── Operations - Expenses module ─────────────────────────────────────────
// Port-scoped gate for the entire Expenses + receipt-upload surface.
// Defaults to enabled so existing ports keep the feature on deploy.
// Disabling hides both sidebar entries (Expenses + How to upload
// receipts) AND swaps the routes for a "Module disabled" placeholder so
// bookmarks land on a meaningful page (not a 404) and direct API hits
// are rejected at the layout boundary.
{
key: 'expenses_module_enabled',
section: 'operations.expenses',
label: 'Expenses module',
description:
'When enabled, reps can record expenses and upload receipts (mobile scanner + manual entry). Turning this off hides Expenses + receipt-upload from the sidebar and blocks the routes with a "module disabled" page. Disabling does not delete previously-recorded expense rows.',
type: 'boolean',
scope: 'port',
defaultValue: true,
},
// ─── Operations - Invoices module ─────────────────────────────────────────
// Port-scoped gate for the standalone `/invoices` flow. Audit conclusion
// (2026-05-27, Initiative 1c): the schema is rich (invoices + invoice_line_items
// + invoice_expenses + send/payment routes + PDF) but the dev DB has zero
// rows. The canonical "money received" path goes via `payments`
// (auto-advances pipeline) and the canonical expense-report path goes via
// `expenses → invoices` only for the employee-expense use case. The sidebar
// nav entry was removed earlier; this toggle hides the route too so
// bookmarks land on a clear "module disabled" page instead of an orphaned
// form. Default OFF for new ports; existing ports keep the surface visible
// until an admin explicitly turns it off.
{
key: 'invoices_module_enabled',
section: 'operations.invoices',
label: 'Standalone invoicing module',
description:
'When enabled, the standalone /invoices flow (create invoice → line items → PDF → send → mark paid) is reachable. The canonical "we received money" path in this CRM goes through the Payments tab on an interest (auto-advances pipeline); the standalone invoicing surface is a separate flow primarily for employee expense reports. Disabling hides /invoices entirely (route renders a "module disabled" page); existing rows are preserved.',
type: 'boolean',
scope: 'port',
defaultValue: false,
},
// ─── 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));
}