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: `.` (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/. 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, }, // ─── 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 port’s behalf.', type: 'string', scope: 'port', placeholder: 'partner@example.com, partner2@example.com', }, ]; /** Quick lookup index keyed by setting key. */ const REGISTRY_INDEX = new Map(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)); }