Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
EOI uploads from 'qualified' silently skipped the stage flip. Now also
writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
pdf/templates/{interest,client}-summary, interest-picker, timeline route
all route through canonicalizeStage / stageLabelFor.
Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
(deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
pipeline-column (kanban), interest-columns (list), interest-card,
interest-detail (breadcrumb), client-pipeline-summary +
client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.
Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
canonical BERTH_STATUSES); cleaned from dashboard.service,
dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
"Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
more 2-line wraps on "needs date range"); accepts initialRange?:
DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
rangeToBounds.
Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
berths (where the only active deal touching the berth IS this same
interest). Waits for all competing-queries before committing the
count. Was showing "3 berths unavailable" when only 1 actually had a
competitor.
Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
instead of firstAt so visible timestamp matches the sort key.
Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.
EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
one batched getAllBerthMooringsForInterests call across all groups.
AggregatedFile type + EntityFolderView render the badge linking back
to the parent interest.
External EOI upload dialog
- Title input pre-fills from the derived default via controlled
displayTitle = title || defaultTitle (no setState-in-effect).
EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
with tooltip: the primary IS the canonical "berth for this deal",
excluding it is semantically nonsense.
Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
whenever is_primary=true; update path coerces back to true when the
caller tries to set false on a primary. Backfilled 7 existing rows.
Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
documenso_redirect_url → public_site_url → null. Operators with
public_site_url configured (most ports) now get sensible signer
landing without setting two settings.
World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
filtered Clients page via router.push instead of copying a URL to
clipboard.
Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
folder has children. Lets reps drill into subfolders from the main
content area, not only via the sidebar tree.
Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
param). Interest list passes updatedAt desc so the table header
surfaces the active sort visibly + most-recently-added/edited bubble
to the top.
Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
— explicit input → port's default_new_interest_owner setting →
creator (when not super-admin). Super-admins skipped since they often
create on behalf of other reps.
Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
flipped to true.
Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed
Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
648 lines
28 KiB
TypeScript
648 lines
28 KiB
TypeScript
/**
|
|
* Typed accessors for port-level configuration with env-fallback.
|
|
*
|
|
* Settings are stored in the `system_settings` table keyed by (key, portId).
|
|
* The functions in this module resolve a port's effective configuration for
|
|
* a given domain (email, Documenso, branding, reminders) by reading the
|
|
* port-scoped row first, falling back to the global row, and finally to the
|
|
* env var when neither is set.
|
|
*/
|
|
import { env } from '@/lib/env';
|
|
import { normalizeBrandingUrl } from '@/lib/branding/url';
|
|
import { getSetting } from '@/lib/services/settings.service';
|
|
|
|
// ─── Setting key constants ───────────────────────────────────────────────────
|
|
|
|
export const SETTING_KEYS = {
|
|
// Email
|
|
emailFromName: 'email_from_name',
|
|
emailFromAddress: 'email_from_address',
|
|
emailReplyTo: 'email_reply_to',
|
|
// §1.4: optional per-port URL that the supplemental-info email links
|
|
// to (typically the marketing site's hosted form). When blank, the
|
|
// built-in CRM route `/public/supplemental-info/<token>` is used.
|
|
supplementalFormUrl: 'supplemental_form_url',
|
|
// email_signature_html / email_footer_html - removed; the email shell
|
|
// reads branding_email_header_html / branding_email_footer_html from
|
|
// /admin/branding, which is the source of truth.
|
|
emailAllowPersonalAccountSends: 'email_allow_personal_account_sends',
|
|
smtpHostOverride: 'smtp_host_override',
|
|
smtpPortOverride: 'smtp_port_override',
|
|
smtpUserOverride: 'smtp_user_override',
|
|
smtpPassOverride: 'smtp_pass_override',
|
|
|
|
// Documenso / EOI
|
|
documensoApiUrlOverride: 'documenso_api_url_override',
|
|
documensoApiKeyOverride: 'documenso_api_key_override',
|
|
documensoApiVersionOverride: 'documenso_api_version_override',
|
|
documensoEoiTemplateId: 'documenso_eoi_template_id',
|
|
// Documenso template recipient slot IDs are per-Documenso-instance
|
|
// numeric values, so they have to follow the per-port template config.
|
|
// Falling back to env keeps single-tenant deploys working.
|
|
documensoClientRecipientId: 'documenso_client_recipient_id',
|
|
documensoDeveloperRecipientId: 'documenso_developer_recipient_id',
|
|
documensoApprovalRecipientId: 'documenso_approval_recipient_id',
|
|
// Per-port Documenso webhook secret - two ports pointed at different
|
|
// Documenso instances cannot share the global env secret. The receiver
|
|
// resolves the matching port by trying each enabled secret with a
|
|
// timing-safe comparison.
|
|
documensoWebhookSecret: 'documenso_webhook_secret',
|
|
eoiDefaultPathway: 'eoi_default_pathway',
|
|
// Identity of the developer + approver that the template's static
|
|
// recipient slots get filled with. Old system hardcoded these
|
|
// (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys
|
|
// need per-port values. Falls back to env or "" if neither set.
|
|
documensoDeveloperName: 'documenso_developer_name',
|
|
documensoDeveloperEmail: 'documenso_developer_email',
|
|
documensoApproverName: 'documenso_approver_name',
|
|
documensoApproverEmail: 'documenso_approver_email',
|
|
// Optional CRM-user binding for the developer + approver slots.
|
|
// When set, the per-port admin UI shows "Linked to <user>" and
|
|
// the webhook handler can match the Documenso developer signer
|
|
// against this user's email for in-CRM signing-status updates.
|
|
// Plan Phase 7 (Project Director RBAC). Stored as the user.id.
|
|
documensoDeveloperUserId: 'documenso_developer_user_id',
|
|
documensoApproverUserId: 'documenso_approver_user_id',
|
|
// Display labels for the developer + approver slots, used in
|
|
// email subjects + signer-progress UI ("Your Project Director,
|
|
// Marie, has signed…"). Defaults to "Developer" / "Approver".
|
|
documensoDeveloperLabel: 'documenso_developer_label',
|
|
documensoApproverLabel: 'documenso_approver_label',
|
|
// Sending behavior for the initial "please sign" invitation email
|
|
// after a document is generated. 'auto' = our branded email goes
|
|
// out immediately; 'manual' = doc generated, signing URL shown in
|
|
// UI, rep clicks a Send button to dispatch. Per-port so different
|
|
// ports can default to different rep workflows.
|
|
eoiSendMode: 'eoi_send_mode',
|
|
// Public-facing host where embedded signing pages live. Used to
|
|
// transform raw Documenso signing URLs into branded
|
|
// {host}/sign/<type>/<token> URLs that go in our outbound emails.
|
|
// Falls back to APP_URL when unset.
|
|
embeddedSigningHost: 'embedded_signing_host',
|
|
// Documenso template IDs for contract / reservation if the port
|
|
// uses templates rather than per-deal uploads. Optional.
|
|
documensoContractTemplateId: 'documenso_contract_template_id',
|
|
documensoReservationTemplateId: 'documenso_reservation_template_id',
|
|
// v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement on
|
|
// multi-recipient envelopes. When SEQUENTIAL is set + apiVersion=v2,
|
|
// Documenso refuses to email recipient N+1 until recipient N has signed.
|
|
// Ignored entirely on v1 instances.
|
|
documensoSigningOrder: 'documenso_signing_order',
|
|
// v2-only override of the post-signing redirect URL set on documentMeta.
|
|
// Resolver chain: explicit override -> port's public_site_url -> null
|
|
// (let Documenso use its own default). Lets signers land on the port's
|
|
// marketing site by default without each admin having to configure two
|
|
// settings.
|
|
documensoRedirectUrl: 'documenso_redirect_url',
|
|
// Per-port public marketing-site URL. Used by signing-redirect
|
|
// fallback, email CTAs, and some templates.
|
|
publicSiteUrl: 'public_site_url',
|
|
|
|
// Branding
|
|
brandingLogoUrl: 'branding_logo_url',
|
|
brandingPrimaryColor: 'branding_primary_color',
|
|
brandingAppName: 'branding_app_name',
|
|
brandingEmailHeaderHtml: 'branding_email_header_html',
|
|
brandingEmailFooterHtml: 'branding_email_footer_html',
|
|
// Phase 5: per-port background image (the blurred overhead photo
|
|
// shown behind the white card in every transactional email + the
|
|
// branded auth shell). Defaults to the Port Nimara overhead photo
|
|
// when blank.
|
|
brandingEmailBackgroundUrl: 'branding_email_background_url',
|
|
|
|
// Reminders (port-level defaults)
|
|
reminderDefaultDays: 'reminder_default_days',
|
|
reminderDefaultEnabled: 'reminder_default_enabled',
|
|
reminderDigestEnabled: 'reminder_digest_enabled',
|
|
reminderDigestTime: 'reminder_digest_time',
|
|
reminderDigestTimezone: 'reminder_digest_timezone',
|
|
|
|
// 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',
|
|
|
|
// Residential partner-forwarding recipients - comma-separated emails
|
|
// that receive a courtesy notification on every new residential
|
|
// inquiry. Blank disables. See createResidentialInterest +
|
|
// forwardResidentialInquiryToPartner for usage.
|
|
residentialPartnerRecipients: 'residential_partner_recipients',
|
|
} 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 ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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;
|
|
// 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 ──────────────────────────────────────────────────────────────────
|
|
|
|
export interface PortEmailConfig {
|
|
fromName: string;
|
|
fromAddress: string;
|
|
replyTo: string | null;
|
|
smtpHost: string;
|
|
smtpPort: number;
|
|
smtpUser: string | null;
|
|
smtpPass: string | null;
|
|
/**
|
|
* When false, only the system (port-config) sender identity is allowed.
|
|
* When true, admins/users may send via their connected personal email
|
|
* account. Defaults to false for safety.
|
|
*/
|
|
allowPersonalAccountSends: boolean;
|
|
/**
|
|
* §1.4: optional per-port URL for the supplemental-info email link.
|
|
* When set, the email contains `${supplementalFormUrl}?token=<raw>`;
|
|
* when null, the built-in CRM route is used.
|
|
*/
|
|
supplementalFormUrl: string | null;
|
|
}
|
|
|
|
export async function getPortEmailConfig(portId: string): Promise<PortEmailConfig> {
|
|
const [
|
|
fromName,
|
|
fromAddress,
|
|
replyTo,
|
|
smtpHost,
|
|
smtpPort,
|
|
smtpUser,
|
|
smtpPass,
|
|
allowPersonalAccountSends,
|
|
supplementalFormUrl,
|
|
] = await Promise.all([
|
|
readSetting<string>(SETTING_KEYS.emailFromName, portId),
|
|
readSetting<string>(SETTING_KEYS.emailFromAddress, portId),
|
|
readSetting<string>(SETTING_KEYS.emailReplyTo, portId),
|
|
readSetting<string>(SETTING_KEYS.smtpHostOverride, portId),
|
|
readSetting<number>(SETTING_KEYS.smtpPortOverride, portId),
|
|
readSetting<string>(SETTING_KEYS.smtpUserOverride, portId),
|
|
readSetting<string>(SETTING_KEYS.smtpPassOverride, portId),
|
|
readSetting<boolean>(SETTING_KEYS.emailAllowPersonalAccountSends, portId),
|
|
readSetting<string>(SETTING_KEYS.supplementalFormUrl, portId),
|
|
]);
|
|
|
|
// Parse env.SMTP_FROM into name + address if no port override
|
|
let envFromName = 'Port Nimara CRM';
|
|
let envFromAddress = `noreply@${env.SMTP_HOST}`;
|
|
if (env.SMTP_FROM) {
|
|
const match = env.SMTP_FROM.match(/^(.+?)\s*<(.+)>$/);
|
|
if (match) {
|
|
envFromName = match[1]!.trim();
|
|
envFromAddress = match[2]!.trim();
|
|
} else {
|
|
envFromAddress = env.SMTP_FROM;
|
|
}
|
|
}
|
|
|
|
return {
|
|
fromName: fromName ?? envFromName,
|
|
fromAddress: fromAddress ?? envFromAddress,
|
|
replyTo: replyTo ?? null,
|
|
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,
|
|
supplementalFormUrl: supplementalFormUrl ?? null,
|
|
};
|
|
}
|
|
|
|
// ─── Documenso ──────────────────────────────────────────────────────────────
|
|
|
|
export type EoiPathway = 'documenso-template' | 'inapp';
|
|
export type DocumensoApiVersion = 'v1' | 'v2';
|
|
export type EoiSendMode = 'auto' | 'manual';
|
|
|
|
export interface PortDocumensoConfig {
|
|
apiUrl: string;
|
|
apiKey: string;
|
|
apiVersion: DocumensoApiVersion;
|
|
/** Resolution provenance - `port` / `global` / `env` / `default` /
|
|
* `none`. Surfaces in DOCUMENSO_AUTH_FAILURE messages so a 401 in
|
|
* prod tells the operator "this came from env fallback" vs "this
|
|
* came from a per-port admin entry" without checking logs. */
|
|
apiKeySource: 'port' | 'global' | 'env' | 'default' | 'none';
|
|
apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none';
|
|
eoiTemplateId: number;
|
|
defaultPathway: EoiPathway;
|
|
/** Documenso template recipient slot IDs (per-instance numeric). */
|
|
clientRecipientId: number;
|
|
developerRecipientId: number;
|
|
approvalRecipientId: number;
|
|
/** Static developer + approver identity per port (was hardcoded in old system). */
|
|
developerName: string;
|
|
developerEmail: string;
|
|
approverName: string;
|
|
approverEmail: string;
|
|
/**
|
|
* Auto = system sends our branded "please sign" email immediately
|
|
* after generation. Manual = generates only; rep clicks a separate
|
|
* Send button. Defaults to 'manual' to match the old system's
|
|
* behavior (which also doesn't auto-send).
|
|
*/
|
|
sendMode: EoiSendMode;
|
|
/**
|
|
* Host that wraps Documenso signing URLs into branded embed URLs.
|
|
* Outbound emails point here for the actual sign UI. e.g.
|
|
* `https://portnimara.com` makes sign URLs look like
|
|
* `https://portnimara.com/sign/<type>/<token>`.
|
|
*/
|
|
embeddedSigningHost: string | null;
|
|
/** Optional template IDs for contract / reservation. null = use
|
|
* upload-and-place-fields per deal instead of templates. */
|
|
contractTemplateId: number | null;
|
|
reservationTemplateId: number | null;
|
|
/** Per-port display labels for the developer + approver slots - drive
|
|
* email subjects and signer-progress UI copy. */
|
|
developerLabel: string;
|
|
approverLabel: string;
|
|
/** Optional CRM-user binding for the developer / approver slots.
|
|
* When set, the per-port admin UI auto-fills name/email from the
|
|
* user's profile and the webhook handler matches against this
|
|
* user's email for in-CRM signing-status updates. */
|
|
developerUserId: string | null;
|
|
approverUserId: string | null;
|
|
/**
|
|
* v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement.
|
|
* `null` keeps the upstream default (PARALLEL); a non-null value gets
|
|
* passed verbatim. v1 instances ignore this - see admin Documenso page.
|
|
*/
|
|
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
|
|
/**
|
|
* v2-only: post-signing redirect URL set on documentMeta. When null,
|
|
* the upstream Documenso default applies (Documenso's own thank-you
|
|
* page). Typically set to `{embeddedSigningHost}/sign/success` so
|
|
* signers land back on the branded marketing site.
|
|
*/
|
|
redirectUrl: string | null;
|
|
}
|
|
|
|
function toIntOrNull(raw: unknown): number | null {
|
|
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
|
if (typeof raw === 'string' && raw.trim()) {
|
|
const n = Number(raw);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function getPortDocumensoConfig(portId: string): Promise<PortDocumensoConfig> {
|
|
const [
|
|
apiUrl,
|
|
apiKey,
|
|
apiVersion,
|
|
eoiTemplateId,
|
|
clientRecipientId,
|
|
developerRecipientId,
|
|
approvalRecipientId,
|
|
defaultPathway,
|
|
developerName,
|
|
developerEmail,
|
|
approverName,
|
|
approverEmail,
|
|
sendMode,
|
|
embeddedSigningHost,
|
|
contractTemplateId,
|
|
reservationTemplateId,
|
|
developerLabel,
|
|
approverLabel,
|
|
developerUserId,
|
|
approverUserId,
|
|
signingOrder,
|
|
redirectUrlOverride,
|
|
publicSiteUrl,
|
|
] = await Promise.all([
|
|
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
|
|
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
|
|
readSetting<DocumensoApiVersion>(SETTING_KEYS.documensoApiVersionOverride, portId),
|
|
readSetting<string | number>(SETTING_KEYS.documensoEoiTemplateId, portId),
|
|
readSetting<string | number>(SETTING_KEYS.documensoClientRecipientId, portId),
|
|
readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId),
|
|
readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId),
|
|
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, 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),
|
|
readSetting<EoiSendMode>(SETTING_KEYS.eoiSendMode, portId),
|
|
readSetting<string>(SETTING_KEYS.embeddedSigningHost, portId),
|
|
readSetting<string | number>(SETTING_KEYS.documensoContractTemplateId, portId),
|
|
readSetting<string | number>(SETTING_KEYS.documensoReservationTemplateId, portId),
|
|
readSetting<string>(SETTING_KEYS.documensoDeveloperLabel, portId),
|
|
readSetting<string>(SETTING_KEYS.documensoApproverLabel, portId),
|
|
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
|
|
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
|
|
readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId),
|
|
readSetting<string>(SETTING_KEYS.documensoRedirectUrl, portId),
|
|
readSetting<string>(SETTING_KEYS.publicSiteUrl, portId),
|
|
]);
|
|
|
|
// Determine the resolution source for the two credentials. Used by
|
|
// the documenso client to enrich 401/403 error messages so operators
|
|
// can tell at a glance whether the failing key is per-port or env.
|
|
type Source = 'port' | 'global' | 'env' | 'default' | 'none';
|
|
const apiUrlSource: Source = apiUrl ? 'port' : env.DOCUMENSO_API_URL ? 'env' : 'none';
|
|
const apiKeySource: Source = apiKey ? 'port' : env.DOCUMENSO_API_KEY ? 'env' : 'none';
|
|
|
|
return {
|
|
// 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,
|
|
apiKeySource,
|
|
apiUrlSource,
|
|
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 ?? '',
|
|
approverName: approverName ?? '',
|
|
approverEmail: approverEmail ?? '',
|
|
sendMode: sendMode ?? 'manual',
|
|
embeddedSigningHost: embeddedSigningHost ?? null,
|
|
contractTemplateId: toIntOrNull(contractTemplateId),
|
|
reservationTemplateId: toIntOrNull(reservationTemplateId),
|
|
developerLabel: developerLabel ?? 'Developer',
|
|
approverLabel: approverLabel ?? 'Approver',
|
|
developerUserId: developerUserId ?? null,
|
|
approverUserId: approverUserId ?? null,
|
|
signingOrder: signingOrder ?? null,
|
|
// Resolution chain: explicit Documenso override → port's marketing
|
|
// site URL → null (Documenso falls back to its own default, which is
|
|
// typically the configured APP_URL = the CRM login - not what we want
|
|
// for signers). The marketing-site fallback means operators who set
|
|
// public_site_url (most do) automatically get sensible signer landing.
|
|
redirectUrl: redirectUrlOverride ?? publicSiteUrl ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List every (portId, webhookSecret) pair configured across the platform,
|
|
* plus a wildcard-port entry for the global env secret. The Documenso
|
|
* webhook receiver iterates the list with `timingSafeEqual` until it
|
|
* finds a match, then dispatches with the resolved portId.
|
|
*
|
|
* `null` portId in the returned array means "matches but no port was
|
|
* resolved" - the caller falls back to the legacy global path.
|
|
*/
|
|
export interface DocumensoSecretEntry {
|
|
portId: string | null;
|
|
secret: string;
|
|
}
|
|
|
|
export async function listDocumensoWebhookSecrets(): Promise<DocumensoSecretEntry[]> {
|
|
const { db } = await import('@/lib/db');
|
|
const { systemSettings } = await import('@/lib/db/schema/system');
|
|
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));
|
|
|
|
const out: DocumensoSecretEntry[] = [];
|
|
for (const row of rows) {
|
|
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,
|
|
// but skipping the entry here keeps the matched-secret loop honest).
|
|
if (env.DOCUMENSO_WEBHOOK_SECRET) {
|
|
out.push({ portId: null, secret: env.DOCUMENSO_WEBHOOK_SECRET });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ─── Branding ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* NOT YET WIRED end-to-end. The `/admin/branding` page persists these
|
|
* settings to system_settings, but the email templates in
|
|
* `src/lib/email/templates/` and the `<BrandedAuthShell>` component in
|
|
* `src/components/shared/branded-auth-shell.tsx` still hardcode the
|
|
* `s3.portnimara.com` logo URL and the Port Nimara color palette. A
|
|
* second port wired into this CRM will see Port Nimara branding in
|
|
* every transactional email until those consumers call
|
|
* `getPortBrandingConfig(portId)`. Tracked as audit finding R2-H15.
|
|
*
|
|
* To wire fully:
|
|
* 1. Take `branding` config as a server-side prop into
|
|
* `<BrandedAuthShell>` (pass it from the page server component).
|
|
* 2. Refactor the email shell helper in each `templates/*.ts` module
|
|
* to take `headerHtml` / `footerHtml` / `primaryColor` instead of
|
|
* the inline constants.
|
|
* 3. In each sender, call `getPortBrandingConfig(portId)` and thread
|
|
* the branding values into the template call.
|
|
*/
|
|
export interface PortBrandingConfig {
|
|
logoUrl: string | null;
|
|
emailBackgroundUrl: string | null;
|
|
primaryColor: string;
|
|
appName: string;
|
|
emailHeaderHtml: string | null;
|
|
emailFooterHtml: string | null;
|
|
}
|
|
|
|
const DEFAULT_BRANDING: PortBrandingConfig = {
|
|
logoUrl: null,
|
|
emailBackgroundUrl: null,
|
|
primaryColor: '#1e293b',
|
|
appName: 'Port Nimara CRM',
|
|
emailHeaderHtml: null,
|
|
emailFooterHtml: null,
|
|
};
|
|
|
|
export async function getPortBrandingConfig(portId: string): Promise<PortBrandingConfig> {
|
|
const [logoUrl, emailBackgroundUrl, primaryColor, appName, emailHeaderHtml, emailFooterHtml] =
|
|
await Promise.all([
|
|
readSetting<string>(SETTING_KEYS.brandingLogoUrl, portId),
|
|
readSetting<string>(SETTING_KEYS.brandingEmailBackgroundUrl, portId),
|
|
readSetting<string>(SETTING_KEYS.brandingPrimaryColor, portId),
|
|
readSetting<string>(SETTING_KEYS.brandingAppName, portId),
|
|
readSetting<string>(SETTING_KEYS.brandingEmailHeaderHtml, portId),
|
|
readSetting<string>(SETTING_KEYS.brandingEmailFooterHtml, portId),
|
|
]);
|
|
|
|
return {
|
|
// Branding URLs that bake a localhost/LAN host (uploaded while running
|
|
// on the dev's Mac) don't resolve from any other device. Normalize
|
|
// here so in-app consumers get a path-only URL the browser resolves
|
|
// against the current origin. Email surfaces re-absolutize via
|
|
// `absolutizeBrandingUrl()` because mail clients have no app origin.
|
|
logoUrl: normalizeBrandingUrl(logoUrl) ?? DEFAULT_BRANDING.logoUrl,
|
|
emailBackgroundUrl:
|
|
normalizeBrandingUrl(emailBackgroundUrl) ?? DEFAULT_BRANDING.emailBackgroundUrl,
|
|
primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor,
|
|
appName: appName ?? DEFAULT_BRANDING.appName,
|
|
emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,
|
|
emailFooterHtml: emailFooterHtml ?? DEFAULT_BRANDING.emailFooterHtml,
|
|
};
|
|
}
|
|
|
|
// ─── Reminders ──────────────────────────────────────────────────────────────
|
|
|
|
export interface PortReminderConfig {
|
|
defaultDays: number;
|
|
defaultEnabled: boolean;
|
|
digestEnabled: boolean;
|
|
digestTime: string; // 'HH:MM'
|
|
digestTimezone: string;
|
|
}
|
|
|
|
const DEFAULT_REMINDER: PortReminderConfig = {
|
|
defaultDays: 7,
|
|
defaultEnabled: false,
|
|
digestEnabled: false,
|
|
digestTime: '09:00',
|
|
digestTimezone: 'Europe/Warsaw',
|
|
};
|
|
|
|
/**
|
|
* Port-level default currency for newly-created berths. Per-berth
|
|
* `priceCurrency` overrides this when set. Defaults to USD because
|
|
* 95% of marinas in the rollout target are USD-denominated.
|
|
*/
|
|
export async function getPortBerthsDefaultCurrency(portId: string): Promise<string> {
|
|
const value = await readSetting<string>(SETTING_KEYS.berthsDefaultCurrency, portId);
|
|
return (value ?? 'USD').trim().toUpperCase() || 'USD';
|
|
}
|
|
|
|
export async function getPortReminderConfig(portId: string): Promise<PortReminderConfig> {
|
|
const [defaultDays, defaultEnabled, digestEnabled, digestTime, digestTimezone] =
|
|
await Promise.all([
|
|
readSetting<number>(SETTING_KEYS.reminderDefaultDays, portId),
|
|
readSetting<boolean>(SETTING_KEYS.reminderDefaultEnabled, portId),
|
|
readSetting<boolean>(SETTING_KEYS.reminderDigestEnabled, portId),
|
|
readSetting<string>(SETTING_KEYS.reminderDigestTime, portId),
|
|
readSetting<string>(SETTING_KEYS.reminderDigestTimezone, portId),
|
|
]);
|
|
|
|
return {
|
|
defaultDays: defaultDays ?? DEFAULT_REMINDER.defaultDays,
|
|
defaultEnabled: defaultEnabled ?? DEFAULT_REMINDER.defaultEnabled,
|
|
digestEnabled: digestEnabled ?? DEFAULT_REMINDER.digestEnabled,
|
|
digestTime: digestTime ?? DEFAULT_REMINDER.digestTime,
|
|
digestTimezone: digestTimezone ?? DEFAULT_REMINDER.digestTimezone,
|
|
};
|
|
}
|