chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -37,7 +37,7 @@ export function isCustomRange(range: DateRange): range is CustomDateRange {
|
||||
* Filename-safe slug for the active range. Used by chart exports so a
|
||||
* PNG/CSV download lands as `pipeline-funnel-30d.png` rather than
|
||||
* `pipeline-funnel-[object Object].png` (which is what a raw template
|
||||
* literal does to a CustomDateRange — Chrome then strips the name and
|
||||
* literal does to a CustomDateRange - Chrome then strips the name and
|
||||
* the file falls back to the blob URL's UUID with no extension).
|
||||
*/
|
||||
export function rangeToSlug(range: DateRange): string {
|
||||
@@ -46,7 +46,7 @@ export function rangeToSlug(range: DateRange): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of rangeToSlug — parses a `?range=<slug>` query-string value
|
||||
* Inverse of rangeToSlug - parses a `?range=<slug>` query-string value
|
||||
* back into a typed DateRange. Returns null on garbage input so callers
|
||||
* can fall through to their "no range" default rather than 400ing.
|
||||
*/
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function resolvePortIdFromSlug(slug: string): Promise<string | null
|
||||
if (!inFlightPortsLookup) {
|
||||
inFlightPortsLookup = (async () => {
|
||||
try {
|
||||
// A17: use /me/ports — works for every authenticated user.
|
||||
// A17: use /me/ports - works for every authenticated user.
|
||||
// The prior code hit /admin/ports which is super-admin-gated, so
|
||||
// sales-reps/viewers fired a wasteful 400 on every page load.
|
||||
const res = await fetch('/api/v1/me/ports', { credentials: 'include' });
|
||||
@@ -46,7 +46,7 @@ export async function resolvePortIdFromSlug(slug: string): Promise<string | null
|
||||
* Client-side fetch wrapper that attaches the `X-Port-Id` header to
|
||||
* every request.
|
||||
*
|
||||
* multi-port-auditor C1: the URL slug is authoritative — Zustand
|
||||
* multi-port-auditor C1: the URL slug is authoritative - Zustand
|
||||
* is a cache that lags by one render after `PortProvider`'s reconcile
|
||||
* effect commits. The previous Zustand-first lookup caused first-load
|
||||
* queries on a freshly-navigated port to fire with the PRIOR port's
|
||||
@@ -65,7 +65,7 @@ export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions =
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the Zustand cache when the URL didn't yield a port —
|
||||
// Fall back to the Zustand cache when the URL didn't yield a port -
|
||||
// e.g. global routes (/dashboard) where the rep hasn't picked a port
|
||||
// yet but a previous session set one.
|
||||
if (!portId) {
|
||||
@@ -147,7 +147,7 @@ export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions =
|
||||
* `/admin/errors/<requestId>`
|
||||
*
|
||||
* Mutations should use the `toastError(err)` helper rather than reading
|
||||
* these fields directly — that keeps the toast format consistent.
|
||||
* these fields directly - that keeps the toast format consistent.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
@@ -156,7 +156,7 @@ export function withAuth<TParams extends RouteParams = Record<string, string>>(
|
||||
// 3. Resolve port context. Port id comes from the X-Port-Id
|
||||
// header (set by the client after port selection), falling
|
||||
// back to the user's default port preference. NEVER from the
|
||||
// request body — SECURITY-GUIDELINES.md §2.1.
|
||||
// request body - SECURITY-GUIDELINES.md §2.1.
|
||||
const portIdFromHeader = req.headers.get('X-Port-Id');
|
||||
const portId =
|
||||
portIdFromHeader ??
|
||||
@@ -283,7 +283,7 @@ export function withAuth<TParams extends RouteParams = Record<string, string>>(
|
||||
/**
|
||||
* Throws ForbiddenError when the caller is not a super-admin. Use inside
|
||||
* route handlers (after withAuth) for endpoints that mutate global, cross-
|
||||
* tenant state — global roles, cross-port migrations, system jobs.
|
||||
* tenant state - global roles, cross-port migrations, system jobs.
|
||||
*
|
||||
* Logs the denied attempt to the audit trail (mirrors withPermission).
|
||||
*/
|
||||
@@ -393,8 +393,8 @@ export function withRateLimit(name: RateLimiterName, handler: RouteHandler): Rou
|
||||
// ─── withPublicContext ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wraps a public (unauthenticated) route — webhooks, health checks,
|
||||
* public APIs — so it runs inside the same `runWithRequestContext` ALS
|
||||
* Wraps a public (unauthenticated) route - webhooks, health checks,
|
||||
* public APIs - so it runs inside the same `runWithRequestContext` ALS
|
||||
* frame that `withAuth` installs for authenticated routes. Without this
|
||||
* frame, `captureErrorEvent`, `getRequestId`, and the logger's request-id
|
||||
* mixin silently no-op for these endpoints, leaving webhook failures
|
||||
@@ -403,7 +403,7 @@ export function withRateLimit(name: RateLimiterName, handler: RouteHandler): Rou
|
||||
* Top-level errors thrown by the handler are forwarded to
|
||||
* `captureErrorEvent` (so they surface in admin/errors) and re-raised
|
||||
* so Next's runtime can return a 500. Webhook handlers that prefer to
|
||||
* always-return-200 can catch internally — this wrapper only catches the
|
||||
* always-return-200 can catch internally - this wrapper only catches the
|
||||
* uncaught path.
|
||||
*/
|
||||
export function withPublicContext(
|
||||
|
||||
@@ -24,7 +24,7 @@ export function parseQuery<T extends ZodSchema>(req: NextRequest, schema: T): z.
|
||||
* H-14: tolerates empty request bodies (content-length 0 or req.json()
|
||||
* throwing on an empty stream) by substituting `{}` so DELETE/PATCH
|
||||
* routes whose schemas have all-optional fields don't crash with a
|
||||
* 500 — the schema's own optionality decides whether the empty object
|
||||
* 500 - the schema's own optionality decides whether the empty object
|
||||
* is a valid input.
|
||||
*/
|
||||
export async function parseBody<T extends ZodSchema>(
|
||||
@@ -53,7 +53,7 @@ export function clientIp(req: NextRequest): string {
|
||||
|
||||
/**
|
||||
* Wraps an unauthenticated route handler with a per-IP (or per-key) rate
|
||||
* limit. Used for portal/auth endpoints that have no session yet — the
|
||||
* limit. Used for portal/auth endpoints that have no session yet - the
|
||||
* `withRateLimit` helper in api/helpers.ts is keyed on `ctx.userId` and
|
||||
* cannot apply here.
|
||||
*
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toast } from 'sonner';
|
||||
import { ApiError } from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* Build a multi-line string suitable for an inline form banner — the
|
||||
* Build a multi-line string suitable for an inline form banner - the
|
||||
* primary message followed by `Error code:` / `Reference ID:` lines when
|
||||
* the error is an ApiError. Use from admin forms that want to keep
|
||||
* their inline error UX instead of switching to a toast.
|
||||
|
||||
@@ -58,7 +58,7 @@ export type AuditAction =
|
||||
// and the FTS GENERATED index missed entirely.
|
||||
| 'outcome_set'
|
||||
| 'outcome_cleared'
|
||||
// Phase 3 — EOI override / contact promote / yacht spawn from EOI.
|
||||
// Phase 3 - EOI override / contact promote / yacht spawn from EOI.
|
||||
// The DB column is free-text per migration 0073; these strings just
|
||||
// formalise the catalogue so the audit-log filter dropdown can surface
|
||||
// them as their own buckets.
|
||||
@@ -165,13 +165,13 @@ function maskValue(value: unknown, depth: number): unknown {
|
||||
if (typeof value === 'string') return maskString(value);
|
||||
if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
|
||||
if (typeof value === 'object') {
|
||||
// Recurse into nested object — only mask keys that themselves look
|
||||
// Recurse into nested object - only mask keys that themselves look
|
||||
// sensitive. Parents stay traversable.
|
||||
return maskObject(value as Record<string, unknown>, depth + 1);
|
||||
}
|
||||
// Non-string primitives (number/boolean/bigint/symbol) at sensitive keys
|
||||
// are passed through unchanged. The original contract was "only mask
|
||||
// strings" — a number at an `email` key is a type error upstream and
|
||||
// strings" - a number at an `email` key is a type error upstream and
|
||||
// shouldn't be silently replaced with `***`.
|
||||
return value;
|
||||
}
|
||||
@@ -275,7 +275,7 @@ export async function createAuditLog(params: AuditLogParams): Promise<void> {
|
||||
fieldChanged: params.fieldChanged ?? null,
|
||||
oldValue: maskSensitiveFields(params.oldValue) ?? null,
|
||||
newValue: maskSensitiveFields(params.newValue) ?? null,
|
||||
// Mask metadata too — the audit found portal-auth, crm-invite,
|
||||
// Mask metadata too - the audit found portal-auth, crm-invite,
|
||||
// hard-delete, and email-accounts services were writing raw emails
|
||||
// into this column.
|
||||
metadata: maskSensitiveFields(params.metadata) ?? null,
|
||||
|
||||
@@ -44,7 +44,7 @@ const trustedOrigins: (request?: Request) => Promise<string[]> = async (request)
|
||||
* is constructed on first property access (i.e. first request) rather than
|
||||
* at module import. This is required so that Next.js's "collect page data"
|
||||
* phase during `pnpm build` doesn't trigger better-auth's "default secret"
|
||||
* check against the unset BETTER_AUTH_SECRET — at build time the auth
|
||||
* check against the unset BETTER_AUTH_SECRET - at build time the auth
|
||||
* config is never accessed, and at runtime the env is fully populated.
|
||||
*
|
||||
* Call sites continue to use `auth.api.foo(...)` unchanged; the Proxy
|
||||
@@ -93,7 +93,7 @@ function buildAuth() {
|
||||
<p style="margin-bottom:16px;">You requested a password reset for your ${appName} account.</p>
|
||||
<p style="margin-bottom:16px;">
|
||||
<a href="${safeUrl(url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
||||
— the link expires in 1 hour.
|
||||
- the link expires in 1 hour.
|
||||
</p>
|
||||
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
||||
`;
|
||||
@@ -143,7 +143,7 @@ function buildAuth() {
|
||||
},
|
||||
},
|
||||
|
||||
// Rate limiting (post-audit F7) — without this, brute-force is wide
|
||||
// Rate limiting (post-audit F7) - without this, brute-force is wide
|
||||
// open. Tight caps on the credential-eating endpoints; loose default
|
||||
// for everything else so legitimate fan-out (multi-widget dashboards
|
||||
// that hit /get-session repeatedly) isn't hampered.
|
||||
|
||||
@@ -15,7 +15,7 @@ const LOCAL_HOST_PATTERNS = [
|
||||
* resolve against whatever origin it loaded the page from.
|
||||
*
|
||||
* Without this, a logo uploaded while the app ran at http://localhost:3000
|
||||
* is forever pinned to that host — fine for the dev's Mac, broken from
|
||||
* is forever pinned to that host - fine for the dev's Mac, broken from
|
||||
* a phone on the LAN or any device with a different DNS view.
|
||||
*
|
||||
* Public-internet URLs (CDN, S3) pass through unchanged.
|
||||
@@ -36,7 +36,7 @@ export function normalizeBrandingUrl(url: string | null | undefined): string | n
|
||||
}
|
||||
|
||||
/**
|
||||
* Email surfaces (rendered HTML inboxes) cannot resolve path-only URLs —
|
||||
* Email surfaces (rendered HTML inboxes) cannot resolve path-only URLs -
|
||||
* the recipient's mail client has no origin context. Use this when
|
||||
* emitting branding into an email shell to guarantee an absolute URL.
|
||||
*
|
||||
|
||||
@@ -78,7 +78,7 @@ export function canonicalizeStage(value: string | null | undefined): PipelineSta
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-friendly label for any stage-like string — modern or legacy. Use
|
||||
* Human-friendly label for any stage-like string - modern or legacy. Use
|
||||
* this in any read surface (activity feed, audit diff, notification copy,
|
||||
* reports) that might be handed pre-migration data.
|
||||
*/
|
||||
@@ -229,7 +229,7 @@ export const BERTH_ACCESS_OPTIONS = [
|
||||
* acronyms keep their canonical casing.
|
||||
*/
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
// 3-letter acronyms — preserve all-caps where the enum stores lowercase.
|
||||
// 3-letter acronyms - preserve all-caps where the enum stores lowercase.
|
||||
vhf: 'VHF',
|
||||
eoi: 'EOI',
|
||||
nda: 'NDA',
|
||||
@@ -251,7 +251,7 @@ function humanizeEnum(raw: string): string {
|
||||
/**
|
||||
* Format an arbitrary enum-shaped string ("hot_lead" → "Hot Lead",
|
||||
* "in_progress" → "In Progress"). Centralised so list columns, badge
|
||||
* components, and detail pages render the same value consistently —
|
||||
* components, and detail pages render the same value consistently -
|
||||
* replaces the scattered ad-hoc `.replace(/_/g, ' ')` calls flagged
|
||||
* by ui-ux-auditor H1.
|
||||
*/
|
||||
@@ -359,10 +359,10 @@ export function formatRole(role: string | null | undefined): string {
|
||||
|
||||
export const OUTCOME_LABELS: Record<string, string> = {
|
||||
won: 'Won',
|
||||
lost_other_marina: 'Lost — chose another marina',
|
||||
lost_unqualified: 'Lost — not qualified',
|
||||
lost_no_response: 'Lost — no response',
|
||||
lost_other: 'Lost — other',
|
||||
lost_other_marina: 'Lost - chose another marina',
|
||||
lost_unqualified: 'Lost - not qualified',
|
||||
lost_no_response: 'Lost - no response',
|
||||
lost_other: 'Lost - other',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export const MAGIC_BYTE_SIGNATURES: Record<string, Uint8Array[]> = {
|
||||
'image/webp': [new Uint8Array([0x52, 0x49, 0x46, 0x46])], // RIFF; WEBP signature follows at offset 8
|
||||
'application/pdf': [new Uint8Array([0x25, 0x50, 0x44, 0x46])], // %PDF
|
||||
// Office formats are zip-based (modern: docx/xlsx) or OLE (legacy: doc/xls).
|
||||
// Both share well-known magic bytes — match either family for a given MIME.
|
||||
// Both share well-known magic bytes - match either family for a given MIME.
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
|
||||
new Uint8Array([0x50, 0x4b, 0x03, 0x04]), // PK\3\4 (zip)
|
||||
new Uint8Array([0x50, 0x4b, 0x05, 0x06]), // empty archive
|
||||
@@ -68,7 +68,7 @@ export const MAGIC_BYTE_SIGNATURES: Record<string, Uint8Array[]> = {
|
||||
new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]), // OLE compound
|
||||
],
|
||||
'application/vnd.ms-excel': [new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])],
|
||||
// text/plain and text/csv have no magic bytes — leave unconstrained;
|
||||
// text/plain and text/csv have no magic bytes - leave unconstrained;
|
||||
// size cap + ALLOWED_MIME_TYPES allow-list is the only gate.
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const connectionString = process.env.DATABASE_URL!;
|
||||
// admin clicking around can saturate 20 slots. Production stays at the
|
||||
// conservative 20 so we don't hammer postgres in a multi-replica deploy.
|
||||
//
|
||||
// 60 was too aggressive locally — postgres + the drizzle logger creates
|
||||
// 60 was too aggressive locally - postgres + the drizzle logger creates
|
||||
// massive log volume that backed up node's stderr, blocking the event
|
||||
// loop on otherwise-cheap requests. 30 is a middle ground that holds
|
||||
// during clients-page fanout without log-storm.
|
||||
@@ -38,7 +38,7 @@ const POOL_MAX = process.env.NODE_ENV === 'development' ? 30 : 20;
|
||||
// - connect_timeout: 5s so failures surface fast instead of stalling
|
||||
// requests for 10s before erroring.
|
||||
// - max_lifetime: 10min so connections recycle before stale sockets
|
||||
// accumulate. Was 30min — too long for the Docker socket-drop pattern.
|
||||
// accumulate. Was 30min - too long for the Docker socket-drop pattern.
|
||||
// - onnotice: surfaces postgres NOTICE/WARNING messages that we'd
|
||||
// otherwise miss (extension warnings, deprecation hints).
|
||||
const queryClient = postgres(connectionString, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Audit-final v2 fix: document_sends FKs default to NO ACTION which means
|
||||
-- a hard-delete of a referenced client/interest/berth/brochure either
|
||||
-- silently blocks the parent delete OR (if a future cascade path is added)
|
||||
-- nukes the send-out audit row. The audit trail must outlast its source —
|
||||
-- nukes the send-out audit row. The audit trail must outlast its source -
|
||||
-- recipient_email + document_kind + body_markdown + from_address are
|
||||
-- already denormalized onto the row for exactly this purpose.
|
||||
--
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Free-text trip / event label so reps can group expenses by yacht show
|
||||
-- or business trip (e.g. "Palm Beach 2026"). Un-normalized on purpose —
|
||||
-- or business trip (e.g. "Palm Beach 2026"). Un-normalized on purpose -
|
||||
-- 6–12 events/year doesn't justify a `trips` table + CRUD UI. The
|
||||
-- autocomplete on the expense form keeps spellings consistent so the
|
||||
-- group-by works.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- every existing row in `roles.permissions`. The schema (RolePermissions
|
||||
-- in src/lib/db/schema/users.ts) added these keys to close the silent-403
|
||||
-- traps on PATCH /api/v1/documents/[id], /cancel, /remind, /watchers, and
|
||||
-- PATCH /api/v1/files/[id] — each used a permission key that did not exist
|
||||
-- PATCH /api/v1/files/[id] - each used a permission key that did not exist
|
||||
-- in the schema, so withPermission()'s `resourcePerms[action]` returned
|
||||
-- undefined and 403'd every non-superadmin call.
|
||||
--
|
||||
@@ -15,7 +15,7 @@
|
||||
-- against a partial run.
|
||||
--
|
||||
-- Note: per-port overrides live in `port_role_overrides.permission_overrides`
|
||||
-- and are PARTIAL — they only contain the keys a port flipped from the
|
||||
-- and are PARTIAL - they only contain the keys a port flipped from the
|
||||
-- base role. The deepMerge resolver fills in `documents.edit` from the
|
||||
-- base role for any port that didn't override it, so we deliberately do
|
||||
-- NOT touch `port_role_overrides` here. Backfilling there would synthesize
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Audit-final v3 — wire the FK columns currently exposed via Drizzle
|
||||
-- Audit-final v3 - wire the FK columns currently exposed via Drizzle
|
||||
-- relations() but missing actual Postgres constraints. relations() only
|
||||
-- configures relational query JOINs; it does NOT install constraints, so
|
||||
-- a service that writes interest_id='nonexistent' faces no DB rejection
|
||||
@@ -7,7 +7,7 @@
|
||||
-- All adds are idempotent (DO blocks swallow duplicate_object) and use
|
||||
-- the NOT VALID + VALIDATE pattern so the brief table-lock phase is
|
||||
-- decoupled from the slow row-scan validation. If validation fails for
|
||||
-- a constraint the migration aborts before later constraints land — a
|
||||
-- a constraint the migration aborts before later constraints land - a
|
||||
-- prod operator can clean the dirty row(s) and re-run.
|
||||
--
|
||||
-- Cascade rule:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Smart-archive feature — add columns that capture WHO archived a client,
|
||||
-- Smart-archive feature - add columns that capture WHO archived a client,
|
||||
-- WHY, and WHAT decisions they made along the way (for the restore
|
||||
-- wizard to attempt reversal).
|
||||
--
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Reconcile the system_settings unique-index drift surfaced in the
|
||||
-- final-deferred audit. The Drizzle schema declares a uniqueIndex on
|
||||
-- (key, port_id), but Postgres treats NULL values as distinct by default.
|
||||
-- That means two rows with `(same_key, NULL)` would BOTH be allowed —
|
||||
-- That means two rows with `(same_key, NULL)` would BOTH be allowed -
|
||||
-- a global-setting collision the index claims to prevent.
|
||||
--
|
||||
-- This was not just theoretical: the dev DB had 60+ duplicate
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
-- (Clients/Companies/Yachts roots + per-entity subfolders) and the
|
||||
-- folder_id pointer to files. Backfill (ensureSystemRoots + per-
|
||||
-- entity subfolders + files.folder_id from entity FKs) runs as a
|
||||
-- separate deploy step — see scripts/backfill-document-folders.ts.
|
||||
-- Idempotent — safe to re-run.
|
||||
-- separate deploy step - see scripts/backfill-document-folders.ts.
|
||||
-- Idempotent - safe to re-run.
|
||||
|
||||
-- ─── document_folders: lifecycle columns ──────────────────────────────────
|
||||
ALTER TABLE "document_folders"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Prod-readiness audit 2026-05-11 follow-ups (A5 + Audit-17 G-I4 + perf).
|
||||
-- Fully idempotent — safe to re-run.
|
||||
-- Fully idempotent - safe to re-run.
|
||||
--
|
||||
-- IMPORTANT: This migration creates indexes CONCURRENTLY, which Postgres
|
||||
-- forbids inside a transaction block. When applying via `psql`, do NOT
|
||||
@@ -75,7 +75,7 @@ END $$;
|
||||
-- so the rebuild rides the new indexes.
|
||||
--
|
||||
-- CONCURRENTLY avoids the ShareLock that blocks writes during a normal
|
||||
-- CREATE INDEX. It can fail mid-build — IF NOT EXISTS skips on re-run,
|
||||
-- CREATE INDEX. It can fail mid-build - IF NOT EXISTS skips on re-run,
|
||||
-- but a failed/invalid index from a prior attempt needs to be dropped
|
||||
-- before this migration succeeds (check `pg_indexes` + `pg_index.indisvalid`).
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
-- 0053 — Measurement units (entry-unit tracking + interest dual-store)
|
||||
-- 0053 - Measurement units (entry-unit tracking + interest dual-store)
|
||||
--
|
||||
-- Problem: dimensions on interests/yachts/berths are stored in ft OR m, but
|
||||
-- the CRM blindly converts between them on display. When a rep edits the
|
||||
-- converted side, the original entered value drifts by floating-point error
|
||||
-- (e.g. 18.29m → 60.039ft → back to 18.297m).
|
||||
--
|
||||
-- Fix: every dimension gets two pieces of info — a value column per unit
|
||||
-- Fix: every dimension gets two pieces of info - a value column per unit
|
||||
-- (so we can render the user's literal entry verbatim) AND a small
|
||||
-- discriminator column (`*_unit`) saying which side the user originally
|
||||
-- typed in. The form prefers the entered unit when displaying; the other
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
-- Constraints (enforced application-side AND in SQL):
|
||||
-- - 2..30 characters
|
||||
-- - lowercase letters, digits, dot, underscore, hyphen
|
||||
-- - case-insensitive uniqueness per install (no per-port scoping —
|
||||
-- - case-insensitive uniqueness per install (no per-port scoping -
|
||||
-- reps move between ports and a global username keeps URLs stable)
|
||||
--
|
||||
-- The column is nullable; existing users keep email-only sign-in until they
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
-- |> apply port_role_overrides for that port
|
||||
-- |> apply user_permission_overrides for (user, port)
|
||||
--
|
||||
-- A user override entry is OPTIONAL — most users will never have one.
|
||||
-- A user override entry is OPTIONAL - most users will never have one.
|
||||
-- When present, the JSONB blob is a Partial<RolePermissions> map where any
|
||||
-- explicitly-set leaf wins over the inherited value (true forces grant,
|
||||
-- false forces deny, missing → inherit).
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
-- confused admin can spam the email-change endpoint to generate
|
||||
-- multiple pending tokens, each emailing the operator's inbox.
|
||||
--
|
||||
-- 3. user_port_roles.userId previously had no FK either — see data-model
|
||||
-- 3. user_port_roles.userId previously had no FK either - see data-model
|
||||
-- H1. Add the same cascade.
|
||||
--
|
||||
-- Each statement is wrapped in DO blocks so the migration is replayable
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
--
|
||||
-- Built with CREATE INDEX CONCURRENTLY so the index build doesn't lock
|
||||
-- the table for new writes. That means each statement must run OUTSIDE
|
||||
-- a transaction — the custom `scripts/db-migrate.ts` runner detects
|
||||
-- a transaction - the custom `scripts/db-migrate.ts` runner detects
|
||||
-- CONCURRENTLY and runs the statement standalone.
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_clients_fulltext
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
-- Rep can author a short note in the upload-for-signing dialog that
|
||||
-- gets inserted above the CTA in every signing-invitation email for
|
||||
-- this document. Plain text (XSS-escaped by the email renderer).
|
||||
-- Null means "no custom message — use the template default".
|
||||
-- Null means "no custom message - use the template default".
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN IF NOT EXISTS invitation_message text;
|
||||
|
||||
@@ -21,7 +21,7 @@ ALTER TABLE interests
|
||||
CREATE INDEX IF NOT EXISTS idx_interests_assigned_to ON interests (assigned_to);
|
||||
|
||||
-- ─── stage value migration (collapse Sent/Signed pairs) ────────────────────
|
||||
-- Dummy-data only — destructive UPDATE is safe.
|
||||
-- Dummy-data only - destructive UPDATE is safe.
|
||||
|
||||
UPDATE interests SET pipeline_stage = 'enquiry'
|
||||
WHERE pipeline_stage IN ('open', 'details_sent', 'in_communication');
|
||||
@@ -86,7 +86,7 @@ INSERT INTO qualification_criteria (port_id, key, label, description, enabled, d
|
||||
FROM ports p
|
||||
ON CONFLICT (port_id, key) DO NOTHING;
|
||||
|
||||
-- These are built but disabled — admins enable per port when relevant.
|
||||
-- These are built but disabled - admins enable per port when relevant.
|
||||
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
|
||||
SELECT p.id, 'signatory', 'Buyer signatory confirmed',
|
||||
'We know who is authorized to sign the EOI on the buyer side (owner / lawyer / company rep).',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
-- 0065_predeploy_schema.sql
|
||||
-- Pre-deploy schema additions per PRE-DEPLOY-PLAN § 1.4:
|
||||
-- 1. berths.archived_at — soft-delete column + partial index, filter for the
|
||||
-- 1. berths.archived_at - soft-delete column + partial index, filter for the
|
||||
-- public berth feed so retired berths stop showing up on the marketing site.
|
||||
-- 2. clients.source_inquiry_id — preserves the linkage from a website inquiry
|
||||
-- 2. clients.source_inquiry_id - preserves the linkage from a website inquiry
|
||||
-- to the client that came out of the "Convert to client" triage step
|
||||
-- (P-4.5). Drives the conversion-funnel-by-source chart.
|
||||
-- 3. email_bounces — bounce-monitoring storage; the IMAP poller writes here
|
||||
-- 3. email_bounces - bounce-monitoring storage; the IMAP poller writes here
|
||||
-- and document_sends / notifications / email_threads consumers can join
|
||||
-- this in to surface "your message bounced" indicators.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
-- The NocoDB importer historically wrote 'auto' to indicate "no override
|
||||
-- in effect" for legacy data. The post-refactor code uses NULL for that
|
||||
-- sentinel and 'manual' / 'automated' for the new states. Mixed values
|
||||
-- pollute the reconcile-queue predicate and the Manual chip — neither
|
||||
-- pollute the reconcile-queue predicate and the Manual chip - neither
|
||||
-- path treats 'auto' specially today, but normalizing closes the gap
|
||||
-- once and for all and keeps the column to a 3-state enum.
|
||||
UPDATE berths
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- the timeline of notes carries the stage they were made at. Read by the
|
||||
-- NotesList UI to render a per-note stage chip.
|
||||
--
|
||||
-- Pre-2026-05-15 rows stay null — backfill from audit_logs would be
|
||||
-- Pre-2026-05-15 rows stay null - backfill from audit_logs would be
|
||||
-- inaccurate (the audit row only captures the AFTER-stage on stage moves,
|
||||
-- not the at-rest state when a note was inserted). New notes carry the
|
||||
-- stamp going forward.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
--
|
||||
-- Without an explicit action Postgres defaults to NO ACTION, so a hard-
|
||||
-- delete of a parent (client, port, berth, file, document signer) is
|
||||
-- blocked at FK check time — sometimes intentional, often surprising.
|
||||
-- blocked at FK check time - sometimes intentional, often surprising.
|
||||
-- Each FK below now declares whether parent deletion is RESTRICT (block,
|
||||
-- force the operator to archive the parent or unlink the children first)
|
||||
-- or SET NULL (allow the deletion, null the FK so child rows stay around
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- columns used by `buildListQuery` (src/lib/db/query-builder.ts:67).
|
||||
--
|
||||
-- Without these, every `ILIKE '%term%'` predicate sequential-scans
|
||||
-- the entire table. The fix isn't to rewrite the SQL — Postgres will
|
||||
-- the entire table. The fix isn't to rewrite the SQL - Postgres will
|
||||
-- transparently use a `gin_trgm_ops` index for ILIKE patterns once
|
||||
-- one exists on the column.
|
||||
--
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
-- Phase 4 — Reminders expansion (POST-AUDIT-SPEC §2 + MASTER-PLAN §D).
|
||||
-- Phase 4 - Reminders expansion (POST-AUDIT-SPEC §2 + MASTER-PLAN §D).
|
||||
--
|
||||
-- Adds:
|
||||
-- 1. interests.reminder_note — cadence note surfaced in notification body + inbox row.
|
||||
-- 2. reminders.yacht_id — fourth supported entity link (was: client/interest/berth).
|
||||
-- 3. reminders.fired_at — worker idempotency; set once the firing notification is
|
||||
-- 1. interests.reminder_note - cadence note surfaced in notification body + inbox row.
|
||||
-- 2. reminders.yacht_id - fourth supported entity link (was: client/interest/berth).
|
||||
-- 3. reminders.fired_at - worker idempotency; set once the firing notification is
|
||||
-- created so a parallel worker can't double-fire.
|
||||
-- 4. user_profiles.preferences gains `digest_time_of_day` (JSONB key; no DDL).
|
||||
--
|
||||
-- The existing reminders table already carries title/note/dueAt/priority/assignedTo/
|
||||
-- snoozedUntil/googleCalendarEventId — those columns are reused unchanged. No new
|
||||
-- snoozedUntil/googleCalendarEventId - those columns are reused unchanged. No new
|
||||
-- table; standalone tasks set client_id/interest_id/berth_id/yacht_id all NULL.
|
||||
|
||||
ALTER TABLE interests
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase 3 — EOI field override foundation (POST-AUDIT-SPEC §1 + MASTER-PLAN §C).
|
||||
-- Phase 3 - EOI field override foundation (POST-AUDIT-SPEC §1 + MASTER-PLAN §C).
|
||||
--
|
||||
-- This migration is foundation-only. The EOI generate-and-sign endpoint
|
||||
-- + the dialog UI extensions land in subsequent sub-sessions (3a-3d).
|
||||
@@ -9,7 +9,7 @@
|
||||
-- 3. yachts.{source, source_document_id}
|
||||
-- 4. documents.override_* columns mirroring the AcroForm field map
|
||||
--
|
||||
-- audit_actions is a free-text column (text, not enum) — new action verbs
|
||||
-- audit_actions is a free-text column (text, not enum) - new action verbs
|
||||
-- 'eoi_field_override', 'promote_to_primary', 'eoi_spawn_yacht' don't
|
||||
-- need DDL; they just appear in inserted rows.
|
||||
|
||||
@@ -18,7 +18,7 @@ ALTER TABLE client_contacts
|
||||
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual',
|
||||
ADD COLUMN IF NOT EXISTS source_document_id text;
|
||||
|
||||
-- Add FK only if it doesn't exist yet. ON DELETE SET NULL — keep the
|
||||
-- Add FK only if it doesn't exist yet. ON DELETE SET NULL - keep the
|
||||
-- contact row around even if the originating document is deleted.
|
||||
DO $$
|
||||
BEGIN
|
||||
@@ -84,7 +84,7 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ─── documents — per-document overrides ────────────────────────────────
|
||||
-- ─── documents - per-document overrides ────────────────────────────────
|
||||
-- These columns mirror the AcroForm field set per
|
||||
-- docs/eoi-documenso-field-mapping.md. When NULL, the canonical
|
||||
-- client/yacht values flow through unchanged. When set, the EOI uses
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase 6 — IMAP bounce-to-interest linking foundation
|
||||
-- Phase 6 - IMAP bounce-to-interest linking foundation
|
||||
-- (POST-AUDIT-SPEC §14.9 / MASTER-PLAN §F).
|
||||
--
|
||||
-- Schema-only. Parser library + cron worker land in 6b/6c.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
-- This migration adds:
|
||||
-- 1. A nullable `recipient_email` column to document_events.
|
||||
-- 2. A partial unique index on (document_id, recipient_email, event_type)
|
||||
-- where recipient_email IS NOT NULL — so per-recipient events dedup
|
||||
-- where recipient_email IS NOT NULL - so per-recipient events dedup
|
||||
-- independently while legacy events without recipient context fall
|
||||
-- through to the existing signature_hash dedup.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase 4b — email open tracking via a 1×1 pixel endpoint.
|
||||
-- Phase 4b - email open tracking via a 1×1 pixel endpoint.
|
||||
-- Adds a per-send open log + cached aggregates on document_sends.
|
||||
|
||||
ALTER TABLE "document_sends"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase 4c — tracked redirect links for email click-through tracking.
|
||||
-- Phase 4c - tracked redirect links for email click-through tracking.
|
||||
-- A short URL at /q/<slug> redirects to the target and records the
|
||||
-- click against the originating send. Cross-posted to Umami as a
|
||||
-- `link-clicked` event.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
-- in InterestDocumentsTab.
|
||||
--
|
||||
-- 3. Soft FK (`ON DELETE SET NULL`) so a hard-deleted interest doesn't
|
||||
-- orphan the file — the audit trail stays intact and the file remains
|
||||
-- orphan the file - the audit trail stays intact and the file remains
|
||||
-- findable under the parent client folder.
|
||||
--
|
||||
-- Apply in dev:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- 2026-05-21: interest_field_history table.
|
||||
--
|
||||
-- Captures field-level overrides — every time a value on an interest
|
||||
-- Captures field-level overrides - every time a value on an interest
|
||||
-- or its linked client changes via a supplemental-info form (or any
|
||||
-- future channel that explicitly records overrides), a row lands here
|
||||
-- with the old + new values plus source attribution.
|
||||
@@ -8,7 +8,7 @@
|
||||
-- Why a separate table vs piggybacking on `audit_logs`:
|
||||
-- - audit_logs is a fire-hose of every CRUD event (~10k rows/day on
|
||||
-- a busy port). Filtering for a single field's history is slow.
|
||||
-- - This table holds ONLY explicit overrides — much smaller, easier
|
||||
-- - This table holds ONLY explicit overrides - much smaller, easier
|
||||
-- to surface on the Interest / Client detail "Field history" panel.
|
||||
-- - The `source` column lets the UI render meaningful provenance
|
||||
-- ("Submitted via supplemental info form on 2026-05-21").
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- 2026-05-21: enforce unique mooring numbers per port at the DB level.
|
||||
--
|
||||
-- The canonical mooring regex is `^[A-Z]+\d+$` (e.g. `A1`, `B12`) and
|
||||
-- the UI gates on uniqueness, but no DB-level constraint existed —
|
||||
-- the UI gates on uniqueness, but no DB-level constraint existed -
|
||||
-- which led to a duplicate E17 row in port-nimara surfaced during UAT.
|
||||
-- Partial unique index lets archived rows reuse a mooring (an old A1
|
||||
-- can be re-issued after the original is archived).
|
||||
|
||||
@@ -41,7 +41,7 @@ export const berths = pgTable(
|
||||
nominalBoatSizeM: numeric('nominal_boat_size_m'),
|
||||
waterDepth: numeric('water_depth'),
|
||||
waterDepthM: numeric('water_depth_m'),
|
||||
/** Entry-unit discriminators — see interests.desiredLengthUnit comment. */
|
||||
/** Entry-unit discriminators - see interests.desiredLengthUnit comment. */
|
||||
lengthUnit: text('length_unit').notNull().default('ft'),
|
||||
widthUnit: text('width_unit').notNull().default('ft'),
|
||||
draftUnit: text('draft_unit').notNull().default('ft'),
|
||||
@@ -95,7 +95,7 @@ export const berths = pgTable(
|
||||
// Pointer to the active per-berth PDF version (Phase 6b). Null until a
|
||||
// rep uploads the first PDF; a later rollback can re-target this column
|
||||
// to any prior `berth_pdf_versions.id`. The full history lives in the
|
||||
// junction table — this column is just the "current" pointer.
|
||||
// junction table - this column is just the "current" pointer.
|
||||
currentPdfVersionId: text('current_pdf_version_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -219,7 +219,7 @@ export const berthMaintenanceLog = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-berth PDF version history (Phase 6b — see plan §3.3 / §4.7b).
|
||||
* Per-berth PDF version history (Phase 6b - see plan §3.3 / §4.7b).
|
||||
*
|
||||
* Each upload creates a new row with a monotonic `versionNumber` per berth.
|
||||
* The active version is referenced by `berths.current_pdf_version_id`. The
|
||||
@@ -247,7 +247,7 @@ export const berthPdfVersions = pgTable(
|
||||
contentSha256: text('content_sha256').notNull(),
|
||||
uploadedBy: text('uploaded_by').notNull(),
|
||||
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */
|
||||
/** Cached signed-URL expiry per §11.1 - re-sign only when within 1h of expiry. */
|
||||
downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }),
|
||||
/** { engine: 'acroform'|'ocr'|'ai', extracted: {...}, conflicts: [...], appliedFields: [...] } */
|
||||
parseResults: jsonb('parse_results'),
|
||||
|
||||
@@ -16,7 +16,7 @@ import { berths } from './berths';
|
||||
import { user } from './users';
|
||||
|
||||
/**
|
||||
* Port-wide brochures (Phase 7 — see plan §3.3 / §4.8).
|
||||
* Port-wide brochures (Phase 7 - see plan §3.3 / §4.8).
|
||||
*
|
||||
* Each port can have multiple brochures (e.g. "General", "Investor Pack")
|
||||
* with one marked as `isDefault`. Archived brochures stay queryable for
|
||||
@@ -73,20 +73,20 @@ export const brochureVersions = pgTable(
|
||||
contentSha256: text('content_sha256').notNull(),
|
||||
uploadedBy: text('uploaded_by').notNull(),
|
||||
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */
|
||||
/** Cached signed-URL expiry per §11.1 - re-sign only when within 1h of expiry. */
|
||||
downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [index('idx_brochure_versions_brochure').on(table.brochureId, table.uploadedAt)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Send-out audit log for berth PDFs and brochures (Phase 7 — plan §3.3).
|
||||
* Send-out audit log for berth PDFs and brochures (Phase 7 - plan §3.3).
|
||||
*
|
||||
* One row per recipient per send. `documentKind` discriminates between
|
||||
* `'berth_pdf'` and `'brochure'`; the corresponding `*_version_id` column
|
||||
* pins the exact version sent.
|
||||
*
|
||||
* `berthPdfVersionId` is intentionally a plain text column (no FK) — the
|
||||
* `berthPdfVersionId` is intentionally a plain text column (no FK) - the
|
||||
* referenced table `berth_pdf_versions` is owned by Phase 6b. Loose-coupling
|
||||
* keeps the two phases independent (per Phase 7 task brief).
|
||||
*
|
||||
@@ -106,7 +106,7 @@ export const documentSends = pgTable(
|
||||
/**
|
||||
* Either client_id or interest_id is set (or both). All five FKs use
|
||||
* `onDelete: 'set null'` so the audit row survives if the parent
|
||||
* client/interest/berth/brochure is deleted — `recipient_email`,
|
||||
* client/interest/berth/brochure is deleted - `recipient_email`,
|
||||
* `document_kind`, `body_markdown`, and `from_address` are denormalized
|
||||
* onto the row precisely so the audit trail outlasts the source.
|
||||
*/
|
||||
@@ -116,7 +116,7 @@ export const documentSends = pgTable(
|
||||
/** 'berth_pdf' | 'brochure' */
|
||||
documentKind: text('document_kind').notNull(),
|
||||
berthId: text('berth_id').references(() => berths.id, { onDelete: 'set null' }),
|
||||
/** Forward FK ref — berth_pdf_versions defined in Phase 6b. Loose-coupled. */
|
||||
/** Forward FK ref - berth_pdf_versions defined in Phase 6b. Loose-coupled. */
|
||||
berthPdfVersionId: text('berth_pdf_version_id'),
|
||||
brochureId: text('brochure_id').references(() => brochures.id, { onDelete: 'set null' }),
|
||||
brochureVersionId: text('brochure_version_id').references(() => brochureVersions.id, {
|
||||
@@ -143,14 +143,14 @@ export const documentSends = pgTable(
|
||||
failedAt: timestamp('failed_at', { withTimezone: true }),
|
||||
/** Human-readable failure reason; only meaningful when failedAt is non-null. */
|
||||
errorReason: text('error_reason'),
|
||||
// Phase 6 — async bounce tracking. Populated by the IMAP NDR
|
||||
// Phase 6 - async bounce tracking. Populated by the IMAP NDR
|
||||
// poller (`src/jobs/processors/imap-bounce-poller.ts`) when a
|
||||
// delivery failure message arrives in the configured mailbox and
|
||||
// matches this send via recipient_email + sent_at window.
|
||||
bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo'
|
||||
bounceReason: text('bounce_reason'),
|
||||
bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }),
|
||||
// Phase 4b — email open tracking. When `trackOpens` is true the send
|
||||
// Phase 4b - email open tracking. When `trackOpens` is true the send
|
||||
// includes a 1×1 pixel pointing at /api/public/email-pixel/[sendId].
|
||||
// `firstOpenedAt` + `openCount` are denormalised aggregates so the
|
||||
// sends list can render an "opened" pill without a JOIN.
|
||||
@@ -174,7 +174,7 @@ export const documentSends = pgTable(
|
||||
/**
|
||||
* Per-open log for emails with `trackOpens=true`. The 1×1 pixel
|
||||
* endpoint inserts here on every fetch (Apple Mail privacy proxy will
|
||||
* over-count; most other clients under-count when images are blocked —
|
||||
* over-count; most other clients under-count when images are blocked -
|
||||
* this is the universal email-tracking caveat). Cached aggregates on
|
||||
* `document_sends` keep list rendering fast.
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,7 @@ export const clients = pgTable(
|
||||
/** When this client came out of a "Convert inquiry to client" triage
|
||||
* step, points back at the originating `website_submissions` row.
|
||||
* Drives the conversion-funnel-by-source chart. Migration 0065
|
||||
* installs the FK with ON DELETE SET NULL — Drizzle doesn't reflect
|
||||
* installs the FK with ON DELETE SET NULL - Drizzle doesn't reflect
|
||||
* it here to avoid the cross-file circular import. */
|
||||
sourceInquiryId: text('source_inquiry_id'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
@@ -89,7 +89,7 @@ export const clientContacts = pgTable(
|
||||
label: text('label'), // primary, secondary, work, personal, broker, assistant
|
||||
isPrimary: boolean('is_primary').notNull().default(false),
|
||||
notes: text('notes'),
|
||||
// Phase 3 — origin tracking.
|
||||
// Phase 3 - origin tracking.
|
||||
// source: 'manual' | 'imported' | 'eoi-custom-input'
|
||||
// source_document_id: when source='eoi-custom-input', points at the
|
||||
// EOI document this row was spawned from. Surfaces an [EOI] badge
|
||||
@@ -256,7 +256,7 @@ export const clientAddresses = pgTable(
|
||||
/** ISO-3166-1 alpha-2 country code. */
|
||||
countryIso: text('country_iso'),
|
||||
isPrimary: boolean('is_primary').notNull().default(true),
|
||||
// Phase 3 — origin tracking, same pattern as client_contacts.
|
||||
// Phase 3 - origin tracking, same pattern as client_contacts.
|
||||
source: text('source').notNull().default('manual'),
|
||||
sourceDocumentId: text('source_document_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -39,7 +39,7 @@ export const files = pgTable(
|
||||
* client folder. NULL for client/yacht/company-level uploads.
|
||||
*
|
||||
* Added by migration 0078; not yet wired into ensureEntityFolder
|
||||
* (interest subfolder nesting) — see master UAT line 728+ for the
|
||||
* (interest subfolder nesting) - see master UAT line 728+ for the
|
||||
* remaining work plan.
|
||||
*/
|
||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||||
@@ -64,7 +64,7 @@ export const files = pgTable(
|
||||
index('idx_files_folder').on(table.folderId),
|
||||
index('idx_files_port_folder').on(table.portId, table.folderId),
|
||||
// Composite indexes for the aggregated-projection queries
|
||||
// (`listFilesAggregatedByEntity`) — every join carries a defense-in-
|
||||
// (`listFilesAggregatedByEntity`) - every join carries a defense-in-
|
||||
// depth `port_id` filter so the leading column matters at scale.
|
||||
index('idx_files_port_client').on(table.portId, table.clientId),
|
||||
index('idx_files_port_company').on(table.portId, table.companyId),
|
||||
@@ -84,8 +84,8 @@ export const documents = pgTable(
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||||
// H-01: nullable; tolerate the owning client being hard-deleted (rare —
|
||||
// archive is the normal path — but if it happens the document row
|
||||
// H-01: nullable; tolerate the owning client being hard-deleted (rare -
|
||||
// archive is the normal path - but if it happens the document row
|
||||
// should outlive it so the audit trail stays intact).
|
||||
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
|
||||
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
|
||||
@@ -112,22 +112,22 @@ export const documents = pgTable(
|
||||
signedFileId: text('signed_file_id').references(() => files.id, { onDelete: 'set null' }),
|
||||
isManualUpload: boolean('is_manual_upload').notNull().default(false),
|
||||
/** Email addresses CC'd on the completion notification (the
|
||||
* passive Documenso CC concept — see plan Q4). Per-document set
|
||||
* passive Documenso CC concept - see plan Q4). Per-document set
|
||||
* by the rep; doesn't gate signing. */
|
||||
completionCcEmails: text('completion_cc_emails').array().default([]),
|
||||
/** Optional auto-reminder cadence — when set, a daily worker
|
||||
/** Optional auto-reminder cadence - when set, a daily worker
|
||||
* fires `sendSigningReminder()` for unsigned signers every
|
||||
* N days until they complete. Null = manual reminders only. */
|
||||
autoReminderIntervalDays: integer('auto_reminder_interval_days'),
|
||||
notes: text('notes'),
|
||||
/** Phase 6 polish — rep-authored note inserted above the CTA in
|
||||
/** Phase 6 polish - rep-authored note inserted above the CTA in
|
||||
* every signing-invitation email for THIS document. Falls back to
|
||||
* the empty string when null. Plain-text (XSS-escaped by the
|
||||
* email renderer); not Markdown. */
|
||||
invitationMessage: text('invitation_message'),
|
||||
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
|
||||
reminderCadenceOverride: integer('reminder_cadence_override'),
|
||||
// Phase 3 — per-document field overrides. When NULL, the canonical
|
||||
// Phase 3 - per-document field overrides. When NULL, the canonical
|
||||
// client/yacht record value flows through; when set, this document
|
||||
// uses the override without touching the underlying record. Mirrors
|
||||
// the AcroForm field set per docs/eoi-documenso-field-mapping.md.
|
||||
@@ -166,7 +166,7 @@ export const documents = pgTable(
|
||||
index('idx_docs_documenso_numeric_id').on(table.documensoNumericId),
|
||||
index('idx_docs_folder').on(table.folderId),
|
||||
// Composite indexes for the aggregated-projection queries
|
||||
// (`listInflightWorkflowsAggregatedByEntity`) — every join carries a
|
||||
// (`listInflightWorkflowsAggregatedByEntity`) - every join carries a
|
||||
// defense-in-depth `port_id` filter so the leading column matters at scale.
|
||||
index('idx_docs_port_client').on(table.portId, table.clientId),
|
||||
index('idx_docs_port_company').on(table.portId, table.companyId),
|
||||
@@ -191,12 +191,12 @@ export const documentSigners = pgTable(
|
||||
signedAt: timestamp('signed_at', { withTimezone: true }),
|
||||
signingUrl: text('signing_url'),
|
||||
embeddedUrl: text('embedded_url'),
|
||||
/** Phase 1+2 lifecycle tracking — set by the send-invitation endpoint
|
||||
/** Phase 1+2 lifecycle tracking - set by the send-invitation endpoint
|
||||
* and the Documenso webhook handler respectively. */
|
||||
invitedAt: timestamp('invited_at', { withTimezone: true }),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }),
|
||||
lastReminderSentAt: timestamp('last_reminder_sent_at', { withTimezone: true }),
|
||||
/** Documenso recipient token — used for token-based lookup when the
|
||||
/** Documenso recipient token - used for token-based lookup when the
|
||||
* webhook fires (more robust than email match when one address
|
||||
* serves multiple roles). */
|
||||
signingToken: text('signing_token'),
|
||||
@@ -350,7 +350,7 @@ export const formSubmissions = pgTable(
|
||||
|
||||
/**
|
||||
* Per-port folder tree for organising documents. Self-referencing
|
||||
* via parent_id; null parent = root. Unlimited depth — the UI is the
|
||||
* via parent_id; null parent = root. Unlimited depth - the UI is the
|
||||
* gate (collapsed sidebar tree + breadcrumb header). Cycle prevention
|
||||
* happens in the service layer (parent_id chain walk on insert/move).
|
||||
*
|
||||
@@ -367,7 +367,7 @@ export const documentFolders = pgTable(
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
// Null = root. ON DELETE NO ACTION on the FK (added by migration
|
||||
// 0050) — the service bubbles children up to the deleted folder's
|
||||
// 0050) - the service bubbles children up to the deleted folder's
|
||||
// parent in a transaction instead of cascading.
|
||||
parentId: text('parent_id'),
|
||||
name: text('name').notNull(),
|
||||
|
||||
@@ -40,7 +40,7 @@ export const expenses = pgTable(
|
||||
/**
|
||||
* True when the rep deliberately created the expense WITHOUT a receipt
|
||||
* (e.g. the receipt was lost or never issued). Surfaces a warning at
|
||||
* creation time AND in the PDF export — the legacy parent-company flow
|
||||
* creation time AND in the PDF export - the legacy parent-company flow
|
||||
* may refuse to reimburse expenses without proof, so the warning is
|
||||
* load-bearing for ops.
|
||||
*/
|
||||
@@ -52,7 +52,7 @@ export const expenses = pgTable(
|
||||
/**
|
||||
* Free-text trip / event label so reps can group expenses for one
|
||||
* yacht show or business trip (e.g. "Palm Beach 2026"). Deliberately
|
||||
* un-normalized — events are 6–12/year and full event-management
|
||||
* un-normalized - events are 6–12/year and full event-management
|
||||
* functionality lives outside this CRM. The autocomplete on the
|
||||
* expense form keeps spellings consistent so group-by works.
|
||||
*/
|
||||
@@ -117,7 +117,7 @@ export const invoices = pgTable(
|
||||
paymentDate: date('payment_date'),
|
||||
paymentMethod: text('payment_method'),
|
||||
paymentReference: text('payment_reference'),
|
||||
// H-01: nullable — losing the rendered invoice PDF shouldn't bring
|
||||
// H-01: nullable - losing the rendered invoice PDF shouldn't bring
|
||||
// down the invoice row (totals + payments are the source of truth).
|
||||
pdfFileId: text('pdf_file_id').references(() => files.id, { onDelete: 'set null' }),
|
||||
/** Optional link to a sales interest. When the invoice is paid and `kind`
|
||||
|
||||
@@ -71,7 +71,7 @@ export * from './website-submissions';
|
||||
// Pre-EOI supplemental form tokens
|
||||
export * from './supplemental-forms';
|
||||
|
||||
// Pipeline refactor — qualification criteria, payment records
|
||||
// Pipeline refactor - qualification criteria, payment records
|
||||
export * from './pipeline';
|
||||
|
||||
// Saved PDF-report templates (`/api/v1/reports/templates`).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Every time a field on an interest or its linked client is overridden
|
||||
* via an explicit channel (today: supplemental-info form submission;
|
||||
* future: form-templates, AI-assisted extraction acceptance), a row
|
||||
* lands here. Distinct from `audit_logs` — that table tracks every
|
||||
* lands here. Distinct from `audit_logs` - that table tracks every
|
||||
* CRUD event for compliance; this one tracks only deliberate overrides
|
||||
* so the Interest + Client "Field history" panels can surface them
|
||||
* compactly.
|
||||
@@ -28,7 +28,7 @@ export const interestFieldHistory = pgTable(
|
||||
.references(() => ports.id),
|
||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'cascade' }),
|
||||
/** Denormalized for fast lookup on the Client detail "Field history"
|
||||
* panel — overrides that come in via a supplemental-info form
|
||||
* panel - overrides that come in via a supplemental-info form
|
||||
* carry both interest + client refs. Direct-edit overrides may
|
||||
* only carry one. */
|
||||
clientId: text('client_id').references(() => clients.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -30,7 +30,7 @@ export const interests = pgTable(
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'restrict' }),
|
||||
// H-01: client is required and design intent is archive-first — the
|
||||
// H-01: client is required and design intent is archive-first - the
|
||||
// service-layer hard-delete path nullifies FKs explicitly. RESTRICT
|
||||
// is a defensive backstop against an ad-hoc DB hard-delete that
|
||||
// would otherwise leave the interest pointing at a missing client.
|
||||
@@ -90,7 +90,7 @@ export const interests = pgTable(
|
||||
/** Recommender inputs - dual-stored. ft is the canonical unit the
|
||||
* recommender SQL queries on; m is the human-friendly entry the rep
|
||||
* may have actually typed. The matching `*_unit` column says which
|
||||
* side is source-of-truth — display prefers that side and recomputes
|
||||
* side is source-of-truth - display prefers that side and recomputes
|
||||
* the other so the rep's literal entry doesn't drift through repeated
|
||||
* conversions. Resolver treats nulls as "no constraint" on that axis. */
|
||||
desiredLengthFt: numeric('desired_length_ft'),
|
||||
@@ -188,7 +188,7 @@ export const interestNotes = pgTable(
|
||||
/** Snapshot of the linked interest's pipeline_stage at note creation.
|
||||
* Lets a rep see how the deal's notes evolved across the lifecycle
|
||||
* (e.g. concerns raised at qualified vs after reservation). Backfill
|
||||
* not attempted for pre-2026-05-15 rows — they stay null. */
|
||||
* not attempted for pre-2026-05-15 rows - they stay null. */
|
||||
pipelineStageAtCreation: text('pipeline_stage_at_creation'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -23,7 +23,7 @@ export const reminders = pgTable(
|
||||
status: text('status').notNull().default('pending'), // pending, snoozed, completed, dismissed
|
||||
assignedTo: text('assigned_to'), // user ID
|
||||
createdBy: text('created_by').notNull(),
|
||||
// H-01: nullable — reminder rows stay around as historical follow-up
|
||||
// H-01: nullable - reminder rows stay around as historical follow-up
|
||||
// records even if the linked client/interest/berth is hard-deleted.
|
||||
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
|
||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||||
@@ -216,7 +216,7 @@ export type NewGeneratedReport = typeof generatedReports.$inferInsert;
|
||||
// Per-interaction record of communication with a client about a specific
|
||||
// interest. Sales reps log every email / call / WhatsApp / meeting touch
|
||||
// here so the team has a structured history of "what was the last
|
||||
// conversation about" — beyond the single `dateLastContact` timestamp on
|
||||
// conversation about" - beyond the single `dateLastContact` timestamp on
|
||||
// the interest itself.
|
||||
//
|
||||
// Notes are for free-form thinking / context. This table is for
|
||||
@@ -234,13 +234,13 @@ export const interestContactLog = pgTable(
|
||||
.notNull()
|
||||
.references(() => interests.id, { onDelete: 'cascade' }),
|
||||
/** When the actual conversation happened (not when the log entry
|
||||
* was recorded — those can differ if a rep logs after the fact). */
|
||||
* was recorded - those can differ if a rep logs after the fact). */
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
/** email | phone | whatsapp | in_person | video | other */
|
||||
channel: text('channel').notNull(),
|
||||
/** outbound | inbound — who initiated the contact. */
|
||||
/** outbound | inbound - who initiated the contact. */
|
||||
direction: text('direction').notNull().default('outbound'),
|
||||
/** Short free text — "Discussed yacht size, asked about tax structure". */
|
||||
/** Short free text - "Discussed yacht size, asked about tax structure". */
|
||||
summary: text('summary').notNull(),
|
||||
/** Raw Web Speech API transcript captured at log time, kept separate
|
||||
* from the rep-polished `summary` so the original utterance survives
|
||||
@@ -253,7 +253,7 @@ export const interestContactLog = pgTable(
|
||||
* the interest for follow-up. Stored as the original choice so the
|
||||
* UI can re-render it; the actual reminder lives in `reminders`. */
|
||||
followUpAt: timestamp('follow_up_at', { withTimezone: true }),
|
||||
/** ID of the auto-created reminder, if any — lets us update/cancel
|
||||
/** ID of the auto-created reminder, if any - lets us update/cancel
|
||||
* the reminder when the log entry is edited. */
|
||||
reminderId: text('reminder_id').references(() => reminders.id, { onDelete: 'set null' }),
|
||||
createdBy: text('created_by').notNull(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Pipeline-refactor tables — per-port qualification criteria, per-interest
|
||||
* Pipeline-refactor tables - per-port qualification criteria, per-interest
|
||||
* qualification state, and payment records (no invoice generation).
|
||||
*
|
||||
* See migrations/0062_pipeline_refactor.sql.
|
||||
@@ -73,7 +73,7 @@ export const interestQualifications = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* Payment records. The CRM does NOT generate invoices — clients pay banks
|
||||
* Payment records. The CRM does NOT generate invoices - clients pay banks
|
||||
* directly. We record that money was received (or refunded) with an
|
||||
* optional uploaded receipt for audit purposes.
|
||||
*
|
||||
@@ -95,7 +95,7 @@ export const payments = pgTable(
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
/** 'deposit' | 'balance' | 'refund' | 'other' — `refund` rows carry
|
||||
/** 'deposit' | 'balance' | 'refund' | 'other' - `refund` rows carry
|
||||
* negative amounts so the running total nets out correctly. */
|
||||
paymentType: text('payment_type').notNull(),
|
||||
amount: numeric('amount').notNull(),
|
||||
|
||||
@@ -21,7 +21,7 @@ export const reportTemplates = pgTable(
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
/** Mirrors the discriminator on ReportConfig — 'dashboard' |
|
||||
/** Mirrors the discriminator on ReportConfig - 'dashboard' |
|
||||
* 'clients' | 'berths' | 'interests'. Validated at the route
|
||||
* layer. */
|
||||
kind: text('kind').notNull(),
|
||||
|
||||
@@ -55,7 +55,7 @@ export const berthReservations = pgTable(
|
||||
// Cover the FKs Postgres doesn't auto-index. Without these, deleting
|
||||
// (or restrict-checking) the parent interest / contract file row
|
||||
// requires a full scan of berth_reservations. (idx_br_interest is
|
||||
// already used by berth_recommendations — namespace this one.)
|
||||
// already used by berth_recommendations - namespace this one.)
|
||||
index('idx_brr_interest').on(table.interestId),
|
||||
index('idx_brr_contract_file').on(table.contractFileId),
|
||||
uniqueIndex('idx_br_active')
|
||||
|
||||
@@ -47,7 +47,7 @@ export const residentialClients = pgTable(
|
||||
/**
|
||||
* Optional link to a matching record in the main `clients` table.
|
||||
* Populated by `findAndLinkMatchingMainClient` after create, or
|
||||
* manually via the admin UI. ON DELETE SET NULL — the residential
|
||||
* manually via the admin UI. ON DELETE SET NULL - the residential
|
||||
* record outlives a GDPR wipe of the main client. Migration 0080
|
||||
* adds the FK + supporting index.
|
||||
*/
|
||||
@@ -119,7 +119,7 @@ export const residentialInterests = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* Threaded notes for residential clients — mirror the marina-side
|
||||
* Threaded notes for residential clients - mirror the marina-side
|
||||
* `clientNotes` shape so the polymorphic NotesList component works
|
||||
* with `entityType='residential_clients'`.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* generates one of these rows + emails the client a public link
|
||||
* containing the token. The client fills out a form prefilled with
|
||||
* whatever's already on file (name, address, contacts, yacht info)
|
||||
* and submits — the submission updates the client + interest rows.
|
||||
* and submits - the submission updates the client + interest rows.
|
||||
*
|
||||
* One-shot: `consumedAt` flips on submit, the token can't be reused.
|
||||
* Tokens expire after 30 days even if unused.
|
||||
|
||||
@@ -42,10 +42,10 @@ export const auditLogs = pgTable(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
revertOf: text('revert_of').references((): any => auditLogs.id),
|
||||
metadata: jsonb('metadata').default({}),
|
||||
/** 'info' | 'warning' | 'error' | 'critical' — drives the row badge
|
||||
/** 'info' | 'warning' | 'error' | 'critical' - drives the row badge
|
||||
* in the inspector. Most user actions are 'info'. */
|
||||
severity: text('severity').notNull().default('info'),
|
||||
/** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' — lets the
|
||||
/** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' - lets the
|
||||
* UI filter by event origin without grepping action names. */
|
||||
source: text('source').notNull().default('user'),
|
||||
/** Full-text search column. **Read-only / DB-managed**: the column is
|
||||
@@ -53,7 +53,7 @@ export const auditLogs = pgTable(
|
||||
* 0014_black_banshee.sql (covers action + entity_type + entity_id +
|
||||
* user_id). Drizzle has no first-class marker for generated columns,
|
||||
* so writes through this schema property would be rejected by
|
||||
* Postgres at SQL level — never set this from application code.
|
||||
* Postgres at SQL level - never set this from application code.
|
||||
* M-SC04: documented to prevent accidental write attempts. */
|
||||
searchText: tsvector('search_text'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -140,7 +140,7 @@ export const systemSettings = pgTable(
|
||||
},
|
||||
(table) => [
|
||||
// Migration 0047 rebuilds this index with `NULLS NOT DISTINCT` so a
|
||||
// global setting (port_id IS NULL) is unique by key alone — the
|
||||
// global setting (port_id IS NULL) is unique by key alone - the
|
||||
// default `NULLS DISTINCT` semantics let duplicates accumulate.
|
||||
// Drizzle's `uniqueIndex` builder doesn't surface NULLS NOT DISTINCT,
|
||||
// so the migration is the source of truth for that flag and
|
||||
@@ -287,7 +287,7 @@ export const errorEvents = pgTable(
|
||||
/**
|
||||
* Equal to the request id minted in `withAuth` and surfaced to the
|
||||
* client via `X-Request-Id`. Acting as the PK lets us write
|
||||
* idempotently when duplicate webhook events fire — `ON CONFLICT
|
||||
* idempotently when duplicate webhook events fire - `ON CONFLICT
|
||||
* DO NOTHING` skips re-inserting the same error.
|
||||
*/
|
||||
requestId: text('request_id').primaryKey(),
|
||||
@@ -297,13 +297,13 @@ export const errorEvents = pgTable(
|
||||
userId: text('user_id'),
|
||||
statusCode: integer('status_code').notNull(),
|
||||
method: text('method').notNull(),
|
||||
/** Pathname only (no query string) — keeps PII and tokens out. */
|
||||
/** Pathname only (no query string) - keeps PII and tokens out. */
|
||||
path: text('path').notNull(),
|
||||
errorName: text('error_name'),
|
||||
errorMessage: text('error_message'),
|
||||
/** First 4 KB of the stack — full stacks live in pino, this is for inspector readability. */
|
||||
/** First 4 KB of the stack - full stacks live in pino, this is for inspector readability. */
|
||||
errorStack: text('error_stack'),
|
||||
/** Sanitized request body (max 1 KB) — secret-sounding keys redacted. */
|
||||
/** Sanitized request body (max 1 KB) - secret-sounding keys redacted. */
|
||||
requestBodyExcerpt: text('request_body_excerpt'),
|
||||
userAgent: text('user_agent'),
|
||||
ipAddress: text('ip_address'),
|
||||
|
||||
@@ -5,14 +5,14 @@ import { user } from './users';
|
||||
import { documentSends } from './brochures';
|
||||
|
||||
/**
|
||||
* Phase 4c — tracked redirect links. A short URL `/q/<slug>` records a
|
||||
* Phase 4c - tracked redirect links. A short URL `/q/<slug>` records a
|
||||
* click and 302s the recipient on to `targetUrl`. The matching click
|
||||
* row is fire-and-forget so the redirect stays snappy; an aggregate
|
||||
* `clickCount` on the parent row keeps "was clicked at all" queries
|
||||
* cheap.
|
||||
*
|
||||
* `sendId` is the optional link back to the originating outbound email
|
||||
* — set when the link is minted via the email-composer flow so reps can
|
||||
* - set when the link is minted via the email-composer flow so reps can
|
||||
* see per-email click-throughs. Manual one-off short links leave it null.
|
||||
*/
|
||||
export const trackedLinks = pgTable(
|
||||
@@ -41,7 +41,7 @@ export const trackedLinks = pgTable(
|
||||
);
|
||||
|
||||
/** Per-click log. Apple Mail privacy proxy will pre-fetch tracked link
|
||||
* URLs the same way it does pixels — clicks from iOS users are
|
||||
* URLs the same way it does pixels - clicks from iOS users are
|
||||
* over-counted. Standard email-tracking caveats apply. */
|
||||
export const trackedLinkClicks = pgTable(
|
||||
'tracked_link_clicks',
|
||||
|
||||
@@ -72,7 +72,7 @@ export type RolePermissions = {
|
||||
* an interest). Carved out from `invoices.record_payment` so a port
|
||||
* that does not use the invoicing module at all can still grant
|
||||
* payment-recording rights to sales reps. `view` follows interests.view
|
||||
* at the route level — this gate only governs the UI affordance.
|
||||
* at the route level - this gate only governs the UI affordance.
|
||||
*/
|
||||
payments: {
|
||||
view: boolean;
|
||||
@@ -166,7 +166,7 @@ export type RolePermissions = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-table column visibility — drives the `<ColumnPicker>` and the
|
||||
* Per-table column visibility - drives the `<ColumnPicker>` and the
|
||||
* DataTable `columnVisibility` state. `hiddenColumns` is the source of
|
||||
* truth; an entry's absence means "show this column" (so newly-added
|
||||
* columns show by default for existing users without us having to
|
||||
@@ -198,20 +198,27 @@ export type UserPreferences = {
|
||||
/**
|
||||
* Dashboard widget visibility, keyed by widget id from the registry
|
||||
* in `src/components/dashboard/widget-registry.ts`. Missing keys fall
|
||||
* back to `defaultVisible` from the registry — so adding a new widget
|
||||
* back to `defaultVisible` from the registry - so adding a new widget
|
||||
* surfaces it for everyone without a migration. `false` hides it.
|
||||
*/
|
||||
dashboardWidgets?: Record<string, boolean>;
|
||||
/**
|
||||
* Ordered list of widget ids — drives the dashboard render order so a
|
||||
* rep can drag tiles around and have the layout persist. Missing
|
||||
* widgets (ids not in the array) render after the listed ones in
|
||||
* registry order, so adding a new widget always surfaces it without
|
||||
* a migration. Order is scoped per widget group implicitly — the
|
||||
* shell groups by `widget.group` first (chart / rail / feed) then
|
||||
* sorts within the group by this array.
|
||||
* Ordered list of widget ids for the **desktop / xl layout** (charts
|
||||
* column + rails aside + feed row, side-by-side). Drives the render
|
||||
* order at viewport widths >= 1280px. Missing widgets fall through to
|
||||
* registry order so newly-added widgets always surface.
|
||||
*/
|
||||
dashboardWidgetOrder?: string[];
|
||||
/**
|
||||
* Ordered list of widget ids for the **stacked layout** (single
|
||||
* column at < xl). Reps reasonably want a different order on mobile
|
||||
* vs desktop - Reminders + Activity top on the phone, Pipeline Funnel
|
||||
* top on a 27" monitor. When unset, the dashboard falls back to
|
||||
* `dashboardWidgetOrder` (then registry order) so a rep who only
|
||||
* customized desktop sees the same order on a phone until they
|
||||
* customize there too.
|
||||
*/
|
||||
dashboardWidgetOrderMobile?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -274,7 +281,7 @@ export const userProfiles = pgTable(
|
||||
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
||||
/**
|
||||
* Canonical first/last name pair. Added 2026-05-09 as the primary
|
||||
* source for greetings, invoicing, and DocSign field-merging — the
|
||||
* source for greetings, invoicing, and DocSign field-merging - the
|
||||
* older `displayName` is now kept around as a derived/optional
|
||||
* override (e.g. for nicknames or vanity formatting). When migrating
|
||||
* production, backfill these columns from displayName by splitting
|
||||
@@ -293,7 +300,7 @@ export const userProfiles = pgTable(
|
||||
*/
|
||||
username: text('username'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
/** FK into the polymorphic `files` table — the avatar is stored
|
||||
/** FK into the polymorphic `files` table - the avatar is stored
|
||||
* via getStorageBackend() so an S3↔filesystem swap carries it
|
||||
* without breaking the URL. The legacy `avatarUrl` column is
|
||||
* kept for any external photo sources but the file pointer wins
|
||||
@@ -330,7 +337,7 @@ export const roles = pgTable('roles', {
|
||||
* Per-user permission overrides layered on top of the role's baseline for
|
||||
* a specific port. Each row carries a `Partial<RolePermissions>` map; any
|
||||
* explicitly-set leaf wins over the role + port-role-override chain. Most
|
||||
* users will never have a row here — it exists for the rare "give Alice
|
||||
* users will never have a row here - it exists for the rare "give Alice
|
||||
* the same role as her team but let her run permanent deletes" case.
|
||||
*
|
||||
* Effective permission resolution lives in `getEffectivePermissions` in
|
||||
@@ -344,7 +351,7 @@ export const userPermissionOverrides = pgTable(
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
// onDelete: 'cascade' is intentional here (not 'set null' as a stale 2026-05-12
|
||||
// audit item suggested). A permission override has no semantic value without
|
||||
// the user it grants permissions to — preserving a row with user_id=NULL
|
||||
// the user it grants permissions to - preserving a row with user_id=NULL
|
||||
// would be an orphan with no audit value, since the override is per-user
|
||||
// additive permissions, not a historical event we need to retain.
|
||||
userId: text('user_id')
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ports } from './ports';
|
||||
* Raw capture of every website inquiry submission, dual-written from the
|
||||
* marketing site alongside its existing NocoDB write. Acts as a passive
|
||||
* collector while the website still uses NocoDB as its primary system of
|
||||
* record — the new CRM observes incoming traffic without altering it,
|
||||
* record - the new CRM observes incoming traffic without altering it,
|
||||
* letting us validate the data flow before any cutover.
|
||||
*
|
||||
* v1 deliberately stores the raw payload as JSON without promoting to
|
||||
|
||||
@@ -46,7 +46,7 @@ export const yachts = pgTable(
|
||||
status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away'
|
||||
notes: text('notes'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
// Phase 3 — origin tracking. eoi-generated marks yachts that were
|
||||
// Phase 3 - origin tracking. eoi-generated marks yachts that were
|
||||
// spawned via the EOI dialog's "+ New yacht" inline form, so the
|
||||
// detail page can surface an [EOI] badge + link to the originating
|
||||
// document.
|
||||
|
||||
@@ -64,7 +64,7 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
// Branding intentionally left empty — admin uploads their own assets
|
||||
// Branding intentionally left empty - admin uploads their own assets
|
||||
// via /admin/branding rather than inheriting Port Nimara's look.
|
||||
},
|
||||
];
|
||||
@@ -123,7 +123,7 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
brandingPairs.push(['branding_primary_color', def.primaryColor]);
|
||||
|
||||
for (const [key, value] of brandingPairs) {
|
||||
// Skip when an existing row is already present — preserves admin
|
||||
// Skip when an existing row is already present - preserves admin
|
||||
// edits across re-seeds. Pair (key, portId) is uniquely indexed.
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
@@ -187,7 +187,7 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'residential_partner',
|
||||
description:
|
||||
'External partner who handles residential inquiries. Sees only the residential pages — no marina clients, yachts, berths, or financial data.',
|
||||
'External partner who handles residential inquiries. Sees only the residential pages - no marina clients, yachts, berths, or financial data.',
|
||||
permissions: RESIDENTIAL_PARTNER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
|
||||
@@ -412,7 +412,7 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
},
|
||||
};
|
||||
|
||||
// Residential Partner — for an outside party who handles residential
|
||||
// Residential Partner - for an outside party who handles residential
|
||||
// inquiries on the marina's behalf. Sees only the residential pages and
|
||||
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
||||
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
|
||||
@@ -112,7 +112,7 @@ interface SyntheticClientSpec {
|
||||
/** Archive the CLIENT after creation. When 'rich', fabricate
|
||||
* archive_metadata so the smart-restore wizard surfaces reversals. */
|
||||
archive?: 'simple' | 'rich';
|
||||
/** Acquisition source — varied across the fixture set so the list view
|
||||
/** Acquisition source - varied across the fixture set so the list view
|
||||
* looks like a real funnel rather than a wall of "Manual". */
|
||||
source?: 'website' | 'manual' | 'referral' | 'broker';
|
||||
/** How long ago (in days) this client record was created. Spreads the
|
||||
@@ -131,7 +131,7 @@ interface SyntheticClientSpec {
|
||||
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold.
|
||||
*/
|
||||
/**
|
||||
* Believable demo dataset — names, emails, phone numbers, addresses, and
|
||||
* Believable demo dataset - names, emails, phone numbers, addresses, and
|
||||
* acquisition sources read like a real marina's prospect list rather
|
||||
* than fixtures keyed on enum names. The `tag` field still carries the
|
||||
* stage/role identity for selectors and intra-seed wiring; nothing in
|
||||
@@ -700,7 +700,7 @@ export async function seedSyntheticPortData(
|
||||
// ── 9. Reservations ─────────────────────────────────────────────────────
|
||||
// One active reservation on the under_offer berth held by Carla,
|
||||
// one cancelled on an available berth.
|
||||
// berthReservations requires a yacht — wire both to the charter co.
|
||||
// berthReservations requires a yacht - wire both to the charter co.
|
||||
// flagship since Carla / Olivia don't own yachts yet.
|
||||
const sharedYachtId = charterYachtRow[1]!.id;
|
||||
await tx.insert(berthReservations).values([
|
||||
@@ -793,7 +793,7 @@ export async function seedSyntheticPortData(
|
||||
email: 'rina.resident@test.local',
|
||||
phone: '+1 555 020 0002',
|
||||
source: 'referral' as const,
|
||||
notes: 'Synthetic residential lead — qualified.',
|
||||
notes: 'Synthetic residential lead - qualified.',
|
||||
},
|
||||
])
|
||||
.returning({ id: residentialClients.id });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Synthetic seed (the "every pipeline stage" fixture).
|
||||
*
|
||||
* Bootstraps the same ports/roles/profile as `seed.ts` then loads
|
||||
* `seedSyntheticPortData()` per port — 12 clients, one per pipeline
|
||||
* `seedSyntheticPortData()` per port - 12 clients, one per pipeline
|
||||
* stage plus archive variants, designed for thoroughly testing the
|
||||
* CRM end-to-end.
|
||||
*
|
||||
|
||||
@@ -44,7 +44,7 @@ const FAKER_SEED = 20260512;
|
||||
const WIDE_MARKER = 'wide-synthetic';
|
||||
|
||||
// Acquisition source distribution roughly matching how a real marina
|
||||
// funnel breaks down — most opportunity comes through the website, then
|
||||
// funnel breaks down - most opportunity comes through the website, then
|
||||
// referrals, then brokers, then manual entry. Tweak when product data
|
||||
// gives us better numbers.
|
||||
const SOURCE_DISTRIBUTION: Array<{
|
||||
@@ -96,7 +96,7 @@ export async function seedWideSyntheticPortData(
|
||||
.where(eq(berths.portId, portId));
|
||||
|
||||
if (portBerths.length === 0) {
|
||||
console.warn(` [${portSlug}] no berths in port — wide seed skipping`);
|
||||
console.warn(` [${portSlug}] no berths in port - wide seed skipping`);
|
||||
return { clients: 0, interests: 0 };
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ export async function seedWideSyntheticPortData(
|
||||
|
||||
if (interest) {
|
||||
interestsInserted++;
|
||||
// ~50% of interests link to a berth — late-stage flow needs
|
||||
// ~50% of interests link to a berth - late-stage flow needs
|
||||
// one, early-stage doesn't have to.
|
||||
if (faker.number.float({ min: 0, max: 1 }) < 0.5) {
|
||||
await tx.insert(interestBerths).values({
|
||||
|
||||
@@ -21,7 +21,7 @@ async function seed() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Seeding Port Nimara CRM (wide synthetic — ${target} clients/port)...`);
|
||||
console.log(`Seeding Port Nimara CRM (wide synthetic - ${target} clients/port)...`);
|
||||
|
||||
const portIds = await seedBootstrap();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
|
||||
import { db } from './index';
|
||||
|
||||
/**
|
||||
* Drizzle transaction client type — the argument shape `db.transaction`'s
|
||||
* Drizzle transaction client type - the argument shape `db.transaction`'s
|
||||
* callback receives. Exported so service helpers that take a `tx`
|
||||
* parameter can spell the type instead of falling back to `any`.
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ interface AuthShellBranding {
|
||||
* Pre-port-context surfaces (login, forgot-password, set-password,
|
||||
* the better-auth password-reset email) need branding before the user
|
||||
* has picked a port. Resolve against the first active port in the
|
||||
* system — for a single-tenant deploy that's the right port; for a
|
||||
* system - for a single-tenant deploy that's the right port; for a
|
||||
* multi-tenant deploy the operator should host each tenant on its own
|
||||
* subdomain so the wrong-tenant logo doesn't surface here.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Phase 6 — IMAP bounce parser library.
|
||||
* Phase 6 - IMAP bounce parser library.
|
||||
*
|
||||
* Walks an inbound delivery-status notification (DSN / NDR) message and
|
||||
* extracts the original recipient, bounce class (hard / soft / ooo /
|
||||
@@ -10,12 +10,12 @@
|
||||
* 7-day window before updating the send row's bounce_* columns.
|
||||
*
|
||||
* The parser handles three common NDR shapes:
|
||||
* 1. RFC 3464 multipart/report — Postfix, Exim, Sendmail, Gmail. The
|
||||
* 1. RFC 3464 multipart/report - Postfix, Exim, Sendmail, Gmail. The
|
||||
* message has a `message/delivery-status` part whose headers carry
|
||||
* structured Action / Status / Original-Recipient fields.
|
||||
* 2. Microsoft Outlook / Exchange — non-DSN reports with the original
|
||||
* 2. Microsoft Outlook / Exchange - non-DSN reports with the original
|
||||
* recipient embedded in the subject line + body prose.
|
||||
* 3. Out-of-office auto-replies — distinct from bounces; classed as
|
||||
* 3. Out-of-office auto-replies - distinct from bounces; classed as
|
||||
* `ooo` so the UI banner stays informational rather than alarming.
|
||||
*
|
||||
* When the parser can't extract a recipient it returns
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Senders that have a portId call this once and pass the result into
|
||||
* the email template. Senders without a portId (e.g. CRM invite at
|
||||
* create-time before a port is selected) pass null — the shell then
|
||||
* create-time before a port is selected) pass null - the shell then
|
||||
* falls back to neutral defaults (no logo, plain background, slate
|
||||
* accent). Configure per-port branding via /admin/branding.
|
||||
*/
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function sendEmail(
|
||||
const effectiveSubject = env.EMAIL_REDIRECT_TO
|
||||
? `[redirected from ${requestedTo}] ${subject}`
|
||||
: subject;
|
||||
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO — the redirect target
|
||||
// CC/BCC dropped entirely under EMAIL_REDIRECT_TO - the redirect target
|
||||
// already gets the message; CCing additional recipients would defeat
|
||||
// the dev safety net.
|
||||
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : cc;
|
||||
@@ -165,12 +165,12 @@ export async function sendEmail(
|
||||
|
||||
// When EMAIL_REDIRECT_TO is set we elevate to `warn` so the dev-only
|
||||
// safety net is visible in any logger config. Prod boot already refuses
|
||||
// when both are set (see env.ts superRefine) — this catches the dev /
|
||||
// when both are set (see env.ts superRefine) - this catches the dev /
|
||||
// staging window where someone left it in a .env by mistake.
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.warn(
|
||||
{ messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId },
|
||||
'Email sent (REDIRECTED via EMAIL_REDIRECT_TO — recipient overridden)',
|
||||
'Email sent (REDIRECTED via EMAIL_REDIRECT_TO - recipient overridden)',
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { TemplateKey } from '@/lib/email/template-catalog';
|
||||
|
||||
export async function resolveSubject(args: {
|
||||
key: TemplateKey;
|
||||
/** Optional — when omitted (e.g. system-level emails with no port
|
||||
/** Optional - when omitted (e.g. system-level emails with no port
|
||||
* context), only the fallback subject is returned. */
|
||||
portId?: string | null;
|
||||
fallback: string;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* don't each inline a different copy of the boilerplate.
|
||||
*
|
||||
* Per-port branding (R2-H15):
|
||||
* - logoUrl — replaces the default Port Nimara logo image
|
||||
* - primaryColor — used for the page-title accent color
|
||||
* - emailHeaderHtml / emailFooterHtml — admin-authored HTML that
|
||||
* - logoUrl - replaces the default Port Nimara logo image
|
||||
* - primaryColor - used for the page-title accent color
|
||||
* - emailHeaderHtml / emailFooterHtml - admin-authored HTML that
|
||||
* appears above / below the body content (e.g. legal footer,
|
||||
* custom marketing strip). When unset, the existing minimal
|
||||
* "Thank you, {{portName}} CRM" sign-off is rendered by callers.
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { absolutizeBrandingUrl } from '@/lib/branding/url';
|
||||
|
||||
// Neutral defaults — no tenant-specific imagery leaks across ports.
|
||||
// Neutral defaults - no tenant-specific imagery leaks across ports.
|
||||
// When branding hasn't been configured the email renders without a logo
|
||||
// and on a plain off-white background. Admins upload their own assets via
|
||||
// /admin/branding which then flow through via getPortBrandingConfig().
|
||||
@@ -100,12 +100,12 @@ export function brandingPrimaryColor(branding?: BrandingShell | null): string {
|
||||
* URL-safe escaper for `href="..."` interpolations inside email
|
||||
* templates. The email-deliverability audit flagged that every template
|
||||
* inlined `${data.link}` directly into href + visible text without
|
||||
* escaping — a `"` (or worse, a `javascript:` scheme) would break out
|
||||
* escaping - a `"` (or worse, a `javascript:` scheme) would break out
|
||||
* of the attribute or trigger an XSS when the recipient opens the email
|
||||
* in a webmail client that runs scripts.
|
||||
*
|
||||
* Two-step defense:
|
||||
* 1. Scheme allow-list — only http(s), mailto, tel survive; everything
|
||||
* 1. Scheme allow-list - only http(s), mailto, tel survive; everything
|
||||
* else (javascript:, data:, vbscript:, file:, …) is rewritten to
|
||||
* `about:blank`.
|
||||
* 2. HTML-attribute escape on `"`, `<`, `>`, `&`, `'`, backtick.
|
||||
@@ -120,7 +120,7 @@ export function safeUrl(url: string | null | undefined): string {
|
||||
lower.startsWith('https://') ||
|
||||
lower.startsWith('mailto:') ||
|
||||
lower.startsWith('tel:') ||
|
||||
// Relative or root-relative paths are also acceptable — they
|
||||
// Relative or root-relative paths are also acceptable - they
|
||||
// resolve against the host the email links to (rare in transactional
|
||||
// mail but used by tracking pixels and unsubscribe headers).
|
||||
lower.startsWith('/') ||
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface TemplateMetadata {
|
||||
export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
|
||||
portal_activation: {
|
||||
key: 'portal_activation',
|
||||
label: 'Portal — activation invite',
|
||||
label: 'Portal - activation invite',
|
||||
description:
|
||||
'Sent to a client when an admin invites them to activate their portal account. Contains the activation link.',
|
||||
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
|
||||
@@ -53,7 +53,7 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
|
||||
},
|
||||
portal_reset: {
|
||||
key: 'portal_reset',
|
||||
label: 'Portal — password reset',
|
||||
label: 'Portal - password reset',
|
||||
description:
|
||||
'Sent when a portal user requests a password reset. Contains the reset link with a short TTL.',
|
||||
mergeTokens: ['portName', 'recipientName', 'ttlMinutes'],
|
||||
@@ -61,45 +61,45 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
|
||||
},
|
||||
portal_invite_resend: {
|
||||
key: 'portal_invite_resend',
|
||||
label: 'Portal — invite resend',
|
||||
label: 'Portal - invite resend',
|
||||
description: 'Re-sent activation email when an admin resends a pending portal invite.',
|
||||
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
|
||||
defaultSubject: 'Activate your {{portName}} client portal account',
|
||||
},
|
||||
crm_invite: {
|
||||
key: 'crm_invite',
|
||||
label: 'CRM — staff invite',
|
||||
label: 'CRM - staff invite',
|
||||
description: 'Sent to a new staff user when an admin invites them to the CRM.',
|
||||
mergeTokens: ['portName', 'recipientName', 'ttlHours'],
|
||||
defaultSubject: 'You have been invited to {{portName}} CRM',
|
||||
},
|
||||
inquiry_client_confirmation: {
|
||||
key: 'inquiry_client_confirmation',
|
||||
label: 'Inquiry — client confirmation',
|
||||
label: 'Inquiry - client confirmation',
|
||||
description: 'Auto-reply confirmation sent to the client after a website berth inquiry.',
|
||||
mergeTokens: ['portName', 'recipientName', 'mooringNumber'],
|
||||
defaultSubject: 'We received your inquiry — {{portName}}',
|
||||
defaultSubject: 'We received your inquiry - {{portName}}',
|
||||
},
|
||||
inquiry_sales_notification: {
|
||||
key: 'inquiry_sales_notification',
|
||||
label: 'Inquiry — sales notification',
|
||||
label: 'Inquiry - sales notification',
|
||||
description: 'Internal alert sent to the sales team when a new website inquiry arrives.',
|
||||
mergeTokens: ['portName', 'clientName', 'mooringNumber', 'email'],
|
||||
defaultSubject: 'New berth inquiry — {{clientName}}',
|
||||
defaultSubject: 'New berth inquiry - {{clientName}}',
|
||||
},
|
||||
residential_inquiry_client_confirmation: {
|
||||
key: 'residential_inquiry_client_confirmation',
|
||||
label: 'Residential inquiry — client confirmation',
|
||||
label: 'Residential inquiry - client confirmation',
|
||||
description: 'Auto-reply sent to the client after a residential property inquiry.',
|
||||
mergeTokens: ['portName', 'recipientName'],
|
||||
defaultSubject: 'We received your residential inquiry — {{portName}}',
|
||||
defaultSubject: 'We received your residential inquiry - {{portName}}',
|
||||
},
|
||||
residential_inquiry_sales_alert: {
|
||||
key: 'residential_inquiry_sales_alert',
|
||||
label: 'Residential inquiry — sales alert',
|
||||
label: 'Residential inquiry - sales alert',
|
||||
description: 'Internal alert sent to residential sales recipients when an inquiry arrives.',
|
||||
mergeTokens: ['portName', 'clientName', 'email', 'phone'],
|
||||
defaultSubject: 'New residential inquiry — {{clientName}}',
|
||||
defaultSubject: 'New residential inquiry - {{clientName}}',
|
||||
},
|
||||
notification_digest: {
|
||||
key: 'notification_digest',
|
||||
@@ -107,7 +107,7 @@ export const TEMPLATE_CATALOG: Record<TemplateKey, TemplateMetadata> = {
|
||||
description:
|
||||
"Daily roll-up of a rep's pending notifications. Fires from the digest worker; respects per-user opt-out.",
|
||||
mergeTokens: ['portName', 'recipientName', 'unreadCount'],
|
||||
defaultSubject: 'Your {{portName}} CRM digest — {{unreadCount}} updates',
|
||||
defaultSubject: 'Your {{portName}} CRM digest - {{unreadCount}} updates',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -40,5 +40,5 @@ export function applySubjectTokens(
|
||||
}
|
||||
|
||||
// Suppress unused-import lint when the helper is not yet referenced from
|
||||
// every template — every consumer uses `and` once it integrates.
|
||||
// every template - every consumer uses `and` once it integrates.
|
||||
void and;
|
||||
|
||||
@@ -8,7 +8,7 @@ interface InviteData {
|
||||
ttlHours: number;
|
||||
recipientName?: string;
|
||||
isSuperAdmin: boolean;
|
||||
/** Display name for the port — falls back to "Port Nimara" so the
|
||||
/** Display name for the port - falls back to "Port Nimara" so the
|
||||
* pre-multi-tenant default still reads correctly. */
|
||||
portName?: string;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function InviteBody({
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
You've been invited to join the {portName} CRM as a {role}. Use the button below to set
|
||||
your password and activate your account at your convenience — the link will remain valid for{' '}
|
||||
your password and activate your account at your convenience - the link will remain valid for{' '}
|
||||
{ttlHours} hours.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
*
|
||||
* Three template families:
|
||||
*
|
||||
* 1. signingInvitation — sent to a single signer when their turn to sign
|
||||
* 1. signingInvitation - sent to a single signer when their turn to sign
|
||||
* comes up. Used both for initial client invites AND cascading "your
|
||||
* turn" emails when an earlier signer completes.
|
||||
*
|
||||
* 2. signingCompleted — sent to ALL signers (with the finalized signed
|
||||
* 2. signingCompleted - sent to ALL signers (with the finalized signed
|
||||
* PDF as an attachment) when the document reaches a fully signed state.
|
||||
*
|
||||
* 3. signingReminder — sent when a rep nudges manually OR when the
|
||||
* 3. signingReminder - sent when a rep nudges manually OR when the
|
||||
* rate-limited reminder service fires.
|
||||
*
|
||||
* All three use the per-port BrandingShell. The signing URL is expected
|
||||
* to already be embedded-format (e.g. /sign/<type>/<token>) — the caller
|
||||
* to already be embedded-format (e.g. /sign/<type>/<token>) - the caller
|
||||
* does the transformation from the raw Documenso URL.
|
||||
*/
|
||||
|
||||
@@ -50,7 +50,7 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
|
||||
// signer.
|
||||
const leadCopy =
|
||||
role === 'client'
|
||||
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.`
|
||||
? `Your ${data.documentLabel} for ${data.portName} is ready for signing. Click the button below to review and sign - it should only take a couple of minutes.`
|
||||
: role === 'approver'
|
||||
? `An ${data.documentLabel} is awaiting your approval. Please review and sign to finalise the document.`
|
||||
: role === 'developer'
|
||||
@@ -110,7 +110,7 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', marginTop: '18px' }}>
|
||||
Signing happens directly inside our website — your data isn't sent to a third-party
|
||||
Signing happens directly inside our website - your data isn't sent to a third-party
|
||||
signing service.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
@@ -140,7 +140,7 @@ export async function signingInvitationEmail(
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
.replace(/\{\{recipientName\}\}/g, data.recipientName)
|
||||
: `${data.documentLabel} ready to sign — ${data.portName}`;
|
||||
: `${data.documentLabel} ready to sign - ${data.portName}`;
|
||||
|
||||
const body = await render(<InvitationBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function signingCompletedEmail(
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{clientName\}\}/g, data.clientName)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `${data.documentLabel} fully signed — ${data.clientName}`;
|
||||
: `${data.documentLabel} fully signed - ${data.clientName}`;
|
||||
const completedDateStr = formatDate(data.completedAt, 'datetime.medium');
|
||||
|
||||
const body = await render(
|
||||
@@ -250,7 +250,7 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
|
||||
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
We sent you a {data.documentLabel} {data.invitedAgo} that's still awaiting your
|
||||
signature. If you've already signed, please disregard this message — it can take a few
|
||||
signature. If you've already signed, please disregard this message - it can take a few
|
||||
minutes for our system to catch up.
|
||||
</Text>
|
||||
{data.customMessage ? (
|
||||
@@ -315,7 +315,7 @@ export async function signingReminderEmail(
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `Friendly reminder: ${data.documentLabel} still awaiting your signature — ${data.portName}`;
|
||||
: `Friendly reminder: ${data.documentLabel} still awaiting your signature - ${data.portName}`;
|
||||
|
||||
const body = await render(<ReminderBody data={data} accent={accent} />, { pretty: false });
|
||||
|
||||
@@ -350,7 +350,7 @@ function CancelledBody({ data, accent }: { data: CancelledData; accent: string }
|
||||
<Text style={{ marginBottom: '14px', fontSize: '16px', lineHeight: '1.6' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '18px', fontSize: '16px', lineHeight: '1.6' }}>
|
||||
The {data.documentLabel} you were signing for {data.portName} has been cancelled. No further
|
||||
action is required from you — any signing link previously sent is no longer valid.
|
||||
action is required from you - any signing link previously sent is no longer valid.
|
||||
</Text>
|
||||
{data.reason ? (
|
||||
<Text
|
||||
@@ -391,7 +391,7 @@ export async function signingCancelledEmail(
|
||||
? overrides.subject
|
||||
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
: `${data.documentLabel} cancelled — ${data.portName}`;
|
||||
: `${data.documentLabel} cancelled - ${data.portName}`;
|
||||
const body = await render(<CancelledBody data={data} accent={accent} />, { pretty: false });
|
||||
const text = `Dear ${data.recipientName},\n\nThe ${data.documentLabel} you were signing for ${data.portName} has been cancelled. No further action is required.${data.reason ? '\n\nReason: ' + data.reason : ''}\n\nWith warm regards,\nThe ${data.portName} Team`;
|
||||
return {
|
||||
|
||||
@@ -40,7 +40,7 @@ function SalesNotificationBody({
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>Hello,</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px' }}>
|
||||
A new enquiry has come in for <strong>{portName}</strong>. {fullName} has asked us to be in
|
||||
touch — full details below:
|
||||
touch - full details below:
|
||||
</Text>
|
||||
<Text style={detailStyle}>
|
||||
<strong>Name:</strong> {fullName}
|
||||
@@ -61,7 +61,7 @@ function SalesNotificationBody({
|
||||
</Link>{' '}
|
||||
to follow up.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px' }}>— {portName} CRM</Text>
|
||||
<Text style={{ fontSize: '16px' }}>- {portName} CRM</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export async function inquirySalesNotification(
|
||||
const mooringDisplay = data.mooringNumber || 'None';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New enquiry — ${portName}${data.mooringNumber ? ` (Berth ${data.mooringNumber})` : ''}`;
|
||||
: `New enquiry - ${portName}${data.mooringNumber ? ` (Berth ${data.mooringNumber})` : ''}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(
|
||||
@@ -93,7 +93,7 @@ export async function inquirySalesNotification(
|
||||
const text = [
|
||||
'Hello,',
|
||||
'',
|
||||
`A new enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch — full details below:`,
|
||||
`A new enquiry has come in for ${portName}. ${data.fullName} has asked us to be in touch - full details below:`,
|
||||
'',
|
||||
`Name: ${data.fullName}`,
|
||||
`Email: ${data.email}`,
|
||||
@@ -102,7 +102,7 @@ export async function inquirySalesNotification(
|
||||
'',
|
||||
`Open the ${portName} CRM (${data.crmUrl}) to follow up.`,
|
||||
'',
|
||||
`— ${portName} CRM`,
|
||||
`- ${portName} CRM`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
|
||||
@@ -53,7 +53,7 @@ function DigestBody({
|
||||
</Text>
|
||||
<Text style={{ fontSize: '14px', lineHeight: '1.5', margin: '0 0 14px' }}>{greeting}</Text>
|
||||
<Text style={{ fontSize: '14px', lineHeight: '1.5', margin: '0 0 16px' }}>
|
||||
Here's what's waiting for you — <strong>{totalUnread}</strong> item
|
||||
Here's what's waiting for you - <strong>{totalUnread}</strong> item
|
||||
{totalUnread === 1 ? '' : 's'} since your last digest.
|
||||
</Text>
|
||||
<table role="presentation" width="100%" cellSpacing={0} cellPadding={0} border={0}>
|
||||
@@ -120,7 +120,7 @@ export async function notificationDigestEmail(
|
||||
): Promise<{ subject: string; html: string; text: string }> {
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `Your ${data.portName} update — ${data.totalUnread} new item${data.totalUnread === 1 ? '' : 's'}`;
|
||||
: `Your ${data.portName} update - ${data.totalUnread} new item${data.totalUnread === 1 ? '' : 's'}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(<DigestBody {...data} accent={accent} />, { pretty: false });
|
||||
@@ -128,7 +128,7 @@ export async function notificationDigestEmail(
|
||||
const text = [
|
||||
`Your ${data.portName} update`,
|
||||
'',
|
||||
`Here's what's waiting for you — ${data.totalUnread} item${data.totalUnread === 1 ? '' : 's'} since your last digest.`,
|
||||
`Here's what's waiting for you - ${data.totalUnread} item${data.totalUnread === 1 ? '' : 's'} since your last digest.`,
|
||||
'',
|
||||
...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`),
|
||||
'',
|
||||
|
||||
@@ -27,7 +27,7 @@ interface RenderOpts {
|
||||
|
||||
// react-email's `render()` auto-escapes string interpolation, so we don't
|
||||
// need our hand-rolled escapeHtml() on these bodies. Inline styles use
|
||||
// camelCase per CSSProperties — react-email serialises them to
|
||||
// camelCase per CSSProperties - react-email serialises them to
|
||||
// email-client-safe inline `style="..."` attributes on output.
|
||||
|
||||
function ActivationBody({
|
||||
@@ -53,7 +53,7 @@ function ActivationBody({
|
||||
</Text>
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
It's our pleasure to invite you to the {portName} client portal — your private space to
|
||||
It's our pleasure to invite you to the {portName} client portal - your private space to
|
||||
review your berth, manage signed documents, and stay in touch with your sales liaison. The
|
||||
button below will let you set a password and activate your account at your convenience.
|
||||
Please use it within {ttlHours} hours.
|
||||
@@ -119,7 +119,7 @@ function ResetBody({
|
||||
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
|
||||
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
|
||||
We received a request to reset the password on your {portName} client portal account. Use
|
||||
the button below to choose a new one — the link will remain valid for {ttlMinutes} minutes.
|
||||
the button below to choose a new one - the link will remain valid for {ttlMinutes} minutes.
|
||||
</Text>
|
||||
<div style={{ textAlign: 'center', margin: '30px 0' }}>
|
||||
<Button
|
||||
@@ -140,7 +140,7 @@ function ResetBody({
|
||||
</div>
|
||||
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
|
||||
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
|
||||
If you didn't request this, you may safely ignore this message — your existing password
|
||||
If you didn't request this, you may safely ignore this message - your existing password
|
||||
will continue to work.
|
||||
</Text>
|
||||
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
|
||||
@@ -163,7 +163,7 @@ export async function activationEmail(
|
||||
.replace(/\{\{portName\}\}/g, data.portName)
|
||||
.replace(/\{\{recipientName\}\}/g, data.recipientName ?? '')
|
||||
.replace(/\{\{ttlHours\}\}/g, String(data.ttlHours))
|
||||
: `Welcome to ${data.portName} — activate your client portal`;
|
||||
: `Welcome to ${data.portName} - activate your client portal`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
|
||||
const body = await render(<ActivationBody {...data} accent={accent} />, {
|
||||
@@ -173,7 +173,7 @@ export async function activationEmail(
|
||||
const text = [
|
||||
`Welcome to ${data.portName}`,
|
||||
'',
|
||||
`It's our pleasure to invite you to the ${data.portName} client portal — your private space to review your berth, manage signed documents, and stay in touch with your sales liaison.`,
|
||||
`It's our pleasure to invite you to the ${data.portName} client portal - your private space to review your berth, manage signed documents, and stay in touch with your sales liaison.`,
|
||||
`Activate your account by visiting: ${data.link}`,
|
||||
'',
|
||||
`Please use the link within ${data.ttlHours} hours.`,
|
||||
@@ -208,10 +208,10 @@ export async function resetEmail(
|
||||
const text = [
|
||||
`Reset your ${data.portName} portal password`,
|
||||
'',
|
||||
`Use the following link to choose a new password — it will remain valid for ${data.ttlMinutes} minutes:`,
|
||||
`Use the following link to choose a new password - it will remain valid for ${data.ttlMinutes} minutes:`,
|
||||
data.link,
|
||||
'',
|
||||
`If you didn't request this, you may safely ignore this message — your existing password will continue to work.`,
|
||||
`If you didn't request this, you may safely ignore this message - your existing password will continue to work.`,
|
||||
'',
|
||||
`With warm regards,`,
|
||||
`The ${data.portName} Team`,
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function residentialSalesAlert(
|
||||
const portName = data.portName ?? 'our team';
|
||||
const subject = overrides?.subject?.trim()
|
||||
? overrides.subject
|
||||
: `New residential enquiry — ${data.fullName}`;
|
||||
: `New residential enquiry - ${data.fullName}`;
|
||||
const accent = brandingPrimaryColor(overrides?.branding);
|
||||
const body = await render(<SalesAlertBody portName={portName} data={data} accent={accent} />, {
|
||||
pretty: false,
|
||||
|
||||
247
src/lib/email/test-registry.ts
Normal file
247
src/lib/email/test-registry.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Registry of every transactional template the system can emit, with a
|
||||
* pre-baked sample-prop fixture so an admin can fire a realistic
|
||||
* preview to a designated address without needing to trigger the real
|
||||
* upstream flow (a real signing send, a real portal invite, etc.).
|
||||
*
|
||||
* Consumed by `<TestTemplateCard>` (admin → Email page) and
|
||||
* `/api/v1/admin/email/test-template`. New templates land here once
|
||||
* they're plumbed; the UI dropdown reflects the registry at runtime so
|
||||
* adding an entry surfaces it without any UI change.
|
||||
*/
|
||||
|
||||
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
|
||||
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
|
||||
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
|
||||
import { notificationDigestEmail } from '@/lib/email/templates/notification-digest';
|
||||
import {
|
||||
signingInvitationEmail,
|
||||
signingCompletedEmail,
|
||||
signingReminderEmail,
|
||||
signingCancelledEmail,
|
||||
} from '@/lib/email/templates/document-signing';
|
||||
import { inquiryClientConfirmation } from '@/lib/email/templates/inquiry-client-confirmation';
|
||||
import { inquirySalesNotification } from '@/lib/email/templates/inquiry-sales-notification';
|
||||
import {
|
||||
residentialClientConfirmation,
|
||||
residentialSalesAlert,
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
|
||||
export type RenderedEmail = { subject: string; html: string; text?: string };
|
||||
|
||||
export interface TestTemplateMeta {
|
||||
/** Stable id - used as the dropdown value + the POST body key. */
|
||||
id: string;
|
||||
/** Human-facing dropdown label. */
|
||||
label: string;
|
||||
/** One-line description shown under the dropdown to clarify which
|
||||
* real flow fires this template in production. */
|
||||
description: string;
|
||||
/** Renders a fully-formed email with placeholder data baked in. */
|
||||
render: (sample: SampleContext) => Promise<RenderedEmail>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared sample fixture passed to every renderer so the previewed
|
||||
* subject/body line up with the admin's current port. Real flows
|
||||
* resolve these from DB lookups; the tester injects synthetic but
|
||||
* plausible values instead.
|
||||
*/
|
||||
export interface SampleContext {
|
||||
recipientName: string;
|
||||
recipientEmail: string;
|
||||
portName: string;
|
||||
portUrl: string;
|
||||
}
|
||||
|
||||
export const TEST_TEMPLATES: TestTemplateMeta[] = [
|
||||
{
|
||||
id: 'portal_activation',
|
||||
label: 'Portal · Activation invite',
|
||||
description: 'Fires when an admin invites a client to activate their portal account.',
|
||||
render: (s) =>
|
||||
activationEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
link: `${s.portUrl}/portal/activate/sample-token`,
|
||||
ttlHours: 24,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'portal_reset',
|
||||
label: 'Portal · Password reset',
|
||||
description: 'Fires when a portal user requests a password reset link.',
|
||||
render: (s) =>
|
||||
resetEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
link: `${s.portUrl}/portal/reset/sample-token`,
|
||||
ttlMinutes: 120,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'crm_invite',
|
||||
label: 'CRM · Teammate invitation',
|
||||
description: 'Fires when a super-admin invites a new teammate to the CRM.',
|
||||
render: (s) =>
|
||||
crmInviteEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
isSuperAdmin: false,
|
||||
link: `${s.portUrl}/invite/sample-token`,
|
||||
ttlHours: 72,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'admin_email_change',
|
||||
label: 'CRM · Admin email change confirmation',
|
||||
description: 'Fires when an admin updates their CRM login email - confirmation step.',
|
||||
render: (s) =>
|
||||
adminEmailChangeEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
newEmail: s.recipientEmail,
|
||||
changedByDisplayName: 'Sample Admin',
|
||||
loginUrl: `${s.portUrl}/login`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'notification_digest',
|
||||
label: 'Reminders · Notification digest',
|
||||
description: 'Fires on the configured cadence (daily/weekly) with the rep’s open reminders.',
|
||||
render: (s) =>
|
||||
notificationDigestEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
items: [
|
||||
{
|
||||
type: 'reminder',
|
||||
title: 'Follow up with Matthew Ciaccio on Berth A1',
|
||||
description: 'Reservation EOI sent 5 days ago - no response yet.',
|
||||
link: `${s.portUrl}/clients/sample-client-id`,
|
||||
createdAt: new Date(Date.now() - 86_400_000),
|
||||
},
|
||||
{
|
||||
type: 'alert',
|
||||
title: 'Berth B12 PDF parse failed',
|
||||
description: null,
|
||||
link: `${s.portUrl}/berths/sample-berth-id`,
|
||||
createdAt: new Date(Date.now() - 2 * 86_400_000),
|
||||
},
|
||||
],
|
||||
totalUnread: 2,
|
||||
inboxLink: `${s.portUrl}/inbox`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'signing_invitation',
|
||||
label: 'Documenso · Signing invitation',
|
||||
description: 'Fires when the rep dispatches the first signing-invite email for a doc.',
|
||||
render: (s) =>
|
||||
signingInvitationEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
signerRole: 'client',
|
||||
signingUrl: `${s.portUrl}/sign/sample-token`,
|
||||
senderName: 'Sample Sales Manager',
|
||||
customMessage: null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'signing_reminder',
|
||||
label: 'Documenso · Signing reminder',
|
||||
description: 'Fires when a manual reminder is dispatched for an outstanding signer.',
|
||||
render: (s) =>
|
||||
signingReminderEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
signingUrl: `${s.portUrl}/sign/sample-token`,
|
||||
invitedAgo: '5 days ago',
|
||||
customMessage: null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'signing_completed',
|
||||
label: 'Documenso · Fully signed notification',
|
||||
description: 'Fires when every required signer has signed and the document is complete.',
|
||||
render: (s) =>
|
||||
signingCompletedEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
clientName: s.recipientName,
|
||||
completedAt: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'signing_cancelled',
|
||||
label: 'Documenso · Signing cancelled',
|
||||
description: 'Fires when the rep cancels a document mid-signature with notify-recipients.',
|
||||
render: (s) =>
|
||||
signingCancelledEmail({
|
||||
recipientName: s.recipientName,
|
||||
portName: s.portName,
|
||||
documentLabel: 'Sales Contract',
|
||||
reason: 'Customer renegotiated terms; a fresh contract will follow.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'inquiry_client_confirmation',
|
||||
label: 'Public inquiry · Client confirmation',
|
||||
description: 'Fires when a public-site visitor submits the contact form (their copy).',
|
||||
render: (s) =>
|
||||
inquiryClientConfirmation({
|
||||
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
|
||||
mooringNumber: 'A1',
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: s.portName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'inquiry_sales_notification',
|
||||
label: 'Public inquiry · Sales notification',
|
||||
description: 'Fires alongside the client confirmation - alerts the sales rep to a new lead.',
|
||||
render: (s) =>
|
||||
inquirySalesNotification({
|
||||
fullName: s.recipientName,
|
||||
email: s.recipientEmail,
|
||||
phone: '+1 555 0100',
|
||||
mooringNumber: 'A1',
|
||||
crmUrl: `${s.portUrl}/clients/sample-client-id`,
|
||||
portName: s.portName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'residential_client_confirmation',
|
||||
label: 'Residential inquiry · Client confirmation',
|
||||
description: 'Fires when a residential-site visitor submits the contact form.',
|
||||
render: (s) =>
|
||||
residentialClientConfirmation({
|
||||
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
portName: s.portName,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'residential_sales_alert',
|
||||
label: 'Residential inquiry · Sales alert',
|
||||
description: 'Fires alongside the residential client confirmation - alerts the sales team.',
|
||||
render: (s) =>
|
||||
residentialSalesAlert({
|
||||
fullName: s.recipientName,
|
||||
email: s.recipientEmail,
|
||||
phone: '+1 555 0100',
|
||||
placeOfResidence: 'Monaco',
|
||||
preferredContactMethod: 'email',
|
||||
notes: 'Looking for year-round mooring + marina apartment access.',
|
||||
crmDeepLink: `${s.portUrl}/residential/clients/sample-id`,
|
||||
portName: s.portName,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function findTestTemplate(id: string): TestTemplateMeta | undefined {
|
||||
return TEST_TEMPLATES.find((t) => t.id === id);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { env } from '@/lib/env';
|
||||
|
||||
interface InjectOptions {
|
||||
/** Public base URL of the CRM (e.g. https://crm.portnimara.com).
|
||||
* Required so the pixel link is absolute — relative URLs break in
|
||||
* Required so the pixel link is absolute - relative URLs break in
|
||||
* email clients. */
|
||||
appBaseUrl: string;
|
||||
/** UUID of the row in `document_sends`. */
|
||||
|
||||
@@ -17,11 +17,11 @@ const envSchema = z
|
||||
// The settings registry at src/lib/settings/registry.ts wires each of
|
||||
// these into the per-port admin UI with port → global → env → default
|
||||
// precedence. They're optional here so a fresh deploy without an env
|
||||
// file can still boot — the operator configures everything via
|
||||
// file can still boot - the operator configures everything via
|
||||
// /admin/<integration> after first super-admin login. See
|
||||
// docs/superpowers/specs/2026-05-15-env-to-admin-migration-design.md.
|
||||
|
||||
// MinIO / S3 (storage backend) — admin: /admin/storage
|
||||
// MinIO / S3 (storage backend) - admin: /admin/storage
|
||||
MINIO_ENDPOINT: z.string().min(1).optional(),
|
||||
MINIO_PORT: z.coerce.number().int().positive().optional(),
|
||||
MINIO_ACCESS_KEY: z.string().min(1).optional(),
|
||||
@@ -32,7 +32,7 @@ const envSchema = z
|
||||
.optional()
|
||||
.transform((v) => (v == null ? undefined : v === 'true')),
|
||||
|
||||
// Documenso — admin: /admin/documenso
|
||||
// Documenso - admin: /admin/documenso
|
||||
DOCUMENSO_API_URL: z.string().url().optional(),
|
||||
DOCUMENSO_API_KEY: z.string().min(1).optional(),
|
||||
DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'),
|
||||
@@ -42,7 +42,7 @@ const envSchema = z
|
||||
DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().optional(),
|
||||
DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().optional(),
|
||||
|
||||
// Email / SMTP — admin: /admin/email
|
||||
// Email / SMTP - admin: /admin/email
|
||||
SMTP_HOST: z.string().min(1).optional(),
|
||||
SMTP_PORT: z.coerce.number().int().positive().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
@@ -65,19 +65,19 @@ const envSchema = z
|
||||
// Shared secret used by the marketing website's server-side dual-write
|
||||
// helper (POST to /api/public/website-inquiries). Set the SAME value on
|
||||
// the website's CRM_INTAKE_SECRET env. Leave unset in dev/staging until
|
||||
// the website's CRM_INTAKE_URL is also set — without this, the public
|
||||
// the website's CRM_INTAKE_URL is also set - without this, the public
|
||||
// intake endpoint refuses every request.
|
||||
WEBSITE_INTAKE_SECRET: z.string().min(16).optional(),
|
||||
|
||||
// OpenAI (optional)
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
|
||||
// Sentry (optional — when unset the SDK is a no-op)
|
||||
// Sentry (optional - when unset the SDK is a no-op)
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
|
||||
SENTRY_ENVIRONMENT: z.string().optional(),
|
||||
SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(0.1),
|
||||
|
||||
// App URLs — admin: /admin/general (TODO once general admin page exists;
|
||||
// App URLs - admin: /admin/general (TODO once general admin page exists;
|
||||
// for now write via the API: PUT /api/v1/admin/settings/app_url)
|
||||
APP_URL: z.string().url(),
|
||||
PUBLIC_SITE_URL: z.string().url().optional(),
|
||||
@@ -124,7 +124,7 @@ const envSchema = z
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['EMAIL_REDIRECT_TO'],
|
||||
message:
|
||||
'EMAIL_REDIRECT_TO must NOT be set in production — it silently rewrites every outbound email recipient. Unset it before deploying.',
|
||||
'EMAIL_REDIRECT_TO must NOT be set in production - it silently rewrites every outbound email recipient. Unset it before deploying.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Given an `error_events` row, returns a short human-readable label +
|
||||
* a longer hint pointing at the probable root cause. This is best-effort
|
||||
* — the goal is to save the admin five minutes of stack reading on the
|
||||
* - the goal is to save the admin five minutes of stack reading on the
|
||||
* common cases (FK violations, schema drift, external service outages,
|
||||
* timeouts) without giving false confidence on the unusual ones.
|
||||
*
|
||||
@@ -30,7 +30,7 @@ export interface LikelyCulprit {
|
||||
const PG_CODE_HINTS: Record<string, LikelyCulprit> = {
|
||||
'23502': {
|
||||
label: 'NOT NULL violation',
|
||||
hint: 'A required column was missing on insert. Check the validator vs the schema — a recently added .notNull() column may not have a default.',
|
||||
hint: 'A required column was missing on insert. Check the validator vs the schema - a recently added .notNull() column may not have a default.',
|
||||
subsystem: 'db',
|
||||
},
|
||||
'23503': {
|
||||
@@ -50,7 +50,7 @@ const PG_CODE_HINTS: Record<string, LikelyCulprit> = {
|
||||
},
|
||||
'42703': {
|
||||
label: 'Schema drift',
|
||||
hint: 'A column referenced by the query does not exist in the database. The most recent migration probably has not been applied — run pnpm db:push or apply the SQL file.',
|
||||
hint: 'A column referenced by the query does not exist in the database. The most recent migration probably has not been applied - run pnpm db:push or apply the SQL file.',
|
||||
subsystem: 'db',
|
||||
},
|
||||
'42P01': {
|
||||
@@ -143,7 +143,7 @@ const STACK_PATH_HINTS: Array<{ pattern: RegExp; culprit: LikelyCulprit }> = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Classify by free-text scan of the error message — last-resort. */
|
||||
/** Classify by free-text scan of the error message - last-resort. */
|
||||
const MESSAGE_HINTS: Array<{ pattern: RegExp; culprit: LikelyCulprit }> = [
|
||||
{
|
||||
pattern: /econnrefused|enotfound|getaddrinfo/i,
|
||||
@@ -181,7 +181,7 @@ const MESSAGE_HINTS: Array<{ pattern: RegExp; culprit: LikelyCulprit }> = [
|
||||
|
||||
/**
|
||||
* Best-effort culprit classification. Returns null when nothing
|
||||
* matches — the inspector will display "Uncategorized".
|
||||
* matches - the inspector will display "Uncategorized".
|
||||
*/
|
||||
export function classifyError(row: ErrorEvent): LikelyCulprit | null {
|
||||
// 1. Postgres SQLSTATE on the metadata bag.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* add a new one. UI / docs / external integrations may pin to a code.
|
||||
*
|
||||
* The plain-text messages are written for the rep on the phone with
|
||||
* the customer — no "constraint violation", no "FK", no internal
|
||||
* the customer - no "constraint violation", no "FK", no internal
|
||||
* service names. The error code is the only technical artifact the
|
||||
* user sees, alongside the request id (`X-Request-Id`).
|
||||
*/
|
||||
@@ -32,7 +32,7 @@ export interface ErrorCodeEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* The full catalog. Adding a new code is a one-line entry — services
|
||||
* The full catalog. Adding a new code is a one-line entry - services
|
||||
* pass the key to `new CodedError('FOO_BAR')` and the rest is automatic.
|
||||
*/
|
||||
export const ERROR_CODES = {
|
||||
@@ -130,7 +130,7 @@ export const ERROR_CODES = {
|
||||
},
|
||||
BERTHS_VERSION_ALREADY_CURRENT: {
|
||||
status: 409,
|
||||
userMessage: "That PDF version is already the active one — there's nothing to roll back to.",
|
||||
userMessage: "That PDF version is already the active one - there's nothing to roll back to.",
|
||||
},
|
||||
|
||||
// ─── Recommender ────────────────────────────────────────────────────
|
||||
@@ -225,7 +225,7 @@ export const ERROR_CODES = {
|
||||
status: 502,
|
||||
userMessage:
|
||||
'The signing service rejected our request. An admin will need to refresh the API key.',
|
||||
hint: 'Documenso 401/403 — API key likely revoked or rotated.',
|
||||
hint: 'Documenso 401/403 - API key likely revoked or rotated.',
|
||||
},
|
||||
DOCUMENSO_TIMEOUT: {
|
||||
status: 504,
|
||||
@@ -234,7 +234,7 @@ export const ERROR_CODES = {
|
||||
DOCUMENSO_V1_NOT_SUPPORTED: {
|
||||
status: 400,
|
||||
userMessage:
|
||||
'This action requires Documenso 2.x — the connected instance is on the legacy v1 API. Ask an admin to upgrade Documenso, then retry.',
|
||||
'This action requires Documenso 2.x - the connected instance is on the legacy v1 API. Ask an admin to upgrade Documenso, then retry.',
|
||||
hint: 'updateEnvelope and other v2-native endpoints require the envelope API introduced in Documenso 2.0.',
|
||||
},
|
||||
OCR_UPSTREAM_ERROR: {
|
||||
@@ -260,13 +260,13 @@ export const ERROR_CODES = {
|
||||
// ─── Internal post-insert guards ────────────────────────────────────
|
||||
// Surfaced as a generic "something went wrong" toast because the cause
|
||||
// is always a programmer / DB-state issue (returning row absent after a
|
||||
// successful insert, etc.) — the rep can't action it but support can,
|
||||
// successful insert, etc.) - the rep can't action it but support can,
|
||||
// via the request-id lookup. Use only with `internalMessage`.
|
||||
INSERT_RETURNING_EMPTY: {
|
||||
status: 500,
|
||||
userMessage:
|
||||
'Something went wrong on our end. Please try again, and quote the error ID below if it keeps happening.',
|
||||
hint: 'A db.insert(...).returning() came back empty — DB constraint or transaction-rollback bug.',
|
||||
hint: 'A db.insert(...).returning() came back empty - DB constraint or transaction-rollback bug.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeEntry>;
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export class AppError extends Error {
|
||||
export class CodedError extends AppError {
|
||||
/** Optional structured details surfaced to the client. */
|
||||
public details?: unknown;
|
||||
/** Optional verbose message for admin logs only — never sent to client. */
|
||||
/** Optional verbose message for admin logs only - never sent to client. */
|
||||
public internalMessage?: string;
|
||||
|
||||
constructor(code: ErrorCode, opts: { details?: unknown; internalMessage?: string } = {}) {
|
||||
@@ -54,7 +54,7 @@ export class CodedError extends AppError {
|
||||
*/
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(entity: string) {
|
||||
// Plain-text version of "X not found" — the registered code stays
|
||||
// Plain-text version of "X not found" - the registered code stays
|
||||
// generic until callers migrate to specific codes per entity.
|
||||
super(
|
||||
404,
|
||||
@@ -115,7 +115,7 @@ export class RateLimitError extends AppError {
|
||||
* pull the full stack + body excerpt + log lines.
|
||||
*
|
||||
* Never leaks stack traces, internal paths, or DB error details to
|
||||
* the client — that data goes to pino + the error_events row only.
|
||||
* the client - that data goes to pino + the error_events row only.
|
||||
*/
|
||||
export function errorResponse(error: unknown): NextResponse {
|
||||
const requestId = getRequestId();
|
||||
@@ -137,7 +137,7 @@ export function errorResponse(error: unknown): NextResponse {
|
||||
body.retryAfter = error.retryAfter;
|
||||
}
|
||||
// 4xx errors are user-action mistakes (validation, not-found,
|
||||
// permission). They DON'T go to error_events — that table is for
|
||||
// permission). They DON'T go to error_events - that table is for
|
||||
// platform faults the super admin needs to triage. The exception:
|
||||
// when a CodedError carries an internalMessage, persist it under
|
||||
// a debug_events flag so admins can still trace deliberate-throw
|
||||
@@ -165,7 +165,7 @@ export function errorResponse(error: unknown): NextResponse {
|
||||
return NextResponse.json(body, { status: 400, headers });
|
||||
}
|
||||
|
||||
// Unhandled — full details to pino + persist to error_events.
|
||||
// Unhandled - full details to pino + persist to error_events.
|
||||
logger.error({ err: error }, 'Unhandled error');
|
||||
void captureErrorEvent({ statusCode: 500, error });
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* Fetch with a hard wall-clock timeout. Wraps `globalThis.fetch` with an
|
||||
* AbortController so a hung upstream cannot pin a worker concurrency slot
|
||||
* indefinitely (per docs/audit-comprehensive-2026-05-05.md HIGH §§5–6 and
|
||||
* MED §13 — Documenso, OCR, and IMAP all needed this).
|
||||
* MED §13 - Documenso, OCR, and IMAP all needed this).
|
||||
*
|
||||
* - Default timeout is 30s; pass `timeoutMs` to override.
|
||||
* - When the caller already supplies an AbortSignal via `init.signal`, both
|
||||
* sources can abort the request — first one to fire wins.
|
||||
* sources can abort the request - first one to fire wins.
|
||||
* - On timeout the rejection is a `DOMException('TimeoutError')` from
|
||||
* AbortController; callers can introspect via `err.name === 'AbortError'`
|
||||
* plus the timeout flag we attach.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* signing-progress, notification-digest, realtime-toast). A signer would
|
||||
* see "Partially signed", "partially_signed", and "EOI fully signed" for
|
||||
* the same enum state across one session. This module is the single
|
||||
* source of truth — import from here, do not redefine inline.
|
||||
* source of truth - import from here, do not redefine inline.
|
||||
*
|
||||
* If a new lifecycle state arrives in the schema, add it here once.
|
||||
*/
|
||||
@@ -28,7 +28,7 @@ export type DocumentStatus =
|
||||
|
||||
/**
|
||||
* Human label rendered in CRM UI (staff-facing). Use the portal-specific
|
||||
* mapping in `documentStatusLabelForPortal` when rendering to clients —
|
||||
* mapping in `documentStatusLabelForPortal` when rendering to clients -
|
||||
* "Awaiting signatures" reads fine on the inside; clients want
|
||||
* "Awaiting your signature".
|
||||
*/
|
||||
@@ -76,7 +76,7 @@ export const DOCUMENT_STATUS_PILL: Record<DocumentStatus, StatusPillStatus> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The "in-flight" set — useful for hero treatment, banners, "we're
|
||||
* The "in-flight" set - useful for hero treatment, banners, "we're
|
||||
* waiting on action" UI. completed/expired/cancelled are terminal.
|
||||
*/
|
||||
export const DOCUMENT_STATUS_ACTIVE: ReadonlySet<DocumentStatus> = new Set<DocumentStatus>([
|
||||
|
||||
@@ -6,7 +6,7 @@ export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
/**
|
||||
* Mix the active request context (request id, port id, user id) into
|
||||
* EVERY log line emitted within an API request — this is what makes
|
||||
* EVERY log line emitted within an API request - this is what makes
|
||||
* the super-admin error inspector usable: paste a request id into the
|
||||
* search and every log line that fired during that request comes back
|
||||
* keyed to it. Outside a request (queue jobs, scheduled tasks) the
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* Format: `{portSlug}/{entity}/{entityId}/{fileId}.{extension}`
|
||||
*
|
||||
* No user-supplied input should ever be used as path components — only
|
||||
* No user-supplied input should ever be used as path components - only
|
||||
* UUIDs and controlled slugs (SECURITY-GUIDELINES.md §3.4, §7.1).
|
||||
*/
|
||||
export function buildStoragePath(
|
||||
|
||||
@@ -62,7 +62,7 @@ export type Align = 'left' | 'right' | 'center';
|
||||
|
||||
export interface TableColumn<Row> {
|
||||
header: string;
|
||||
/** grow weight (default 1) — controls column width proportions. */
|
||||
/** grow weight (default 1) - controls column width proportions. */
|
||||
flex?: number;
|
||||
align?: Align;
|
||||
render: (row: Row, rowIndex: number) => ReactNode;
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface KeyValueGridProps {
|
||||
}
|
||||
|
||||
function fmt(v: string | number | null | undefined): string {
|
||||
if (v === null || v === undefined || v === '') return '—';
|
||||
if (v === null || v === undefined || v === '') return '-';
|
||||
return typeof v === 'number' ? String(v) : v;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface LineChartProps {
|
||||
yLabel?: string;
|
||||
/** Render circle markers at each point (default true). */
|
||||
markers?: boolean;
|
||||
/** Optional fixed y-axis max — used when value-space should not auto-zoom. */
|
||||
/** Optional fixed y-axis max - used when value-space should not auto-zoom. */
|
||||
yMax?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function loadEoiTemplatePdf(): Promise<Uint8Array> {
|
||||
if (actual !== EXPECTED_EOI_SHA256) {
|
||||
logger.warn(
|
||||
{ expected: EXPECTED_EOI_SHA256, actual },
|
||||
'EOI source PDF sha256 mismatch — template was modified without an EXPECTED_EOI_SHA256 bump. Update assets/README.md + EXPECTED_EOI_SHA256 in lockstep if this was intentional.',
|
||||
'EOI source PDF sha256 mismatch - template was modified without an EXPECTED_EOI_SHA256 bump. Update assets/README.md + EXPECTED_EOI_SHA256 in lockstep if this was intentional.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function formatAddress(address: EoiContext['client']['address']): string {
|
||||
// EOI's Address field renders as: "street, city, REGION, postal, COUNTRY"
|
||||
// with REGION as the ISO-3166-2 suffix (e.g. NY) and COUNTRY as the
|
||||
// alpha-2 code (e.g. US) so the line fits in the PDF box. The separate
|
||||
// `Nationality` PDF field has been retired — the resident's country code
|
||||
// `Nationality` PDF field has been retired - the resident's country code
|
||||
// here is the canonical replacement.
|
||||
return [address.street, address.city, address.subdivision, address.postalCode, address.countryIso]
|
||||
.filter(Boolean)
|
||||
@@ -90,7 +90,7 @@ function setText(form: ReturnType<PDFDocument['getForm']>, name: string, value:
|
||||
if (value && value.trim().length > 0) {
|
||||
logger.warn(
|
||||
{ field: name },
|
||||
`EOI in-app PDF template is missing AcroForm field "${name}" — value was dropped. Update the source template.`,
|
||||
`EOI in-app PDF template is missing AcroForm field "${name}" - value was dropped. Update the source template.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ function setCheckbox(
|
||||
} catch {
|
||||
logger.warn(
|
||||
{ field: name, checked },
|
||||
`EOI in-app PDF template is missing checkbox AcroForm field "${name}" — checkbox state was dropped. Update the source template.`,
|
||||
`EOI in-app PDF template is missing checkbox AcroForm field "${name}" - checkbox state was dropped. Update the source template.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export async function fillEoiFormFields(
|
||||
const yWid = dimUnit === 'ft' ? context.yacht?.widthFt : context.yacht?.widthM;
|
||||
const yDra = dimUnit === 'ft' ? context.yacht?.draftFt : context.yacht?.draftM;
|
||||
// Append the unit suffix so the rendered EOI reads "45 ft" / "13.7 m"
|
||||
// rather than the bare number — matches the Documenso pathway.
|
||||
// rather than the bare number - matches the Documenso pathway.
|
||||
const withDimUnit = (v: string | null | undefined): string =>
|
||||
v && String(v).trim() ? `${String(v).trim()} ${dimUnit}` : '';
|
||||
setText(form, 'Length', withDimUnit(yLen));
|
||||
@@ -153,7 +153,7 @@ export async function fillEoiFormFields(
|
||||
// Berth Number = compact range for multi-berth, primary mooring for
|
||||
// single-berth (formatBerthRange(['A1']) === 'A1' so single-berth is
|
||||
// byte-identical to the legacy primary-only path). The dedicated
|
||||
// `Berth Range` AcroForm field was retired 2026-05-14 — the source
|
||||
// `Berth Range` AcroForm field was retired 2026-05-14 - the source
|
||||
// PDF only carries `Berth Number`.
|
||||
setText(form, 'Berth Number', context.eoiBerthRange || (context.berth?.mooringNumber ?? ''));
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { PDFDocument } from 'pdf-lib';
|
||||
/**
|
||||
* Result of inspecting a PDF's AcroForm. Used by the Documenso template
|
||||
* sync flow to surface what AcroForm fields the operator's uploaded PDF
|
||||
* actually has — so the admin can verify their fillable PDF matches the
|
||||
* actually has - so the admin can verify their fillable PDF matches the
|
||||
* CRM's expected field-label set before any EOI is sent in anger.
|
||||
*/
|
||||
export interface AcroFormField {
|
||||
name: string;
|
||||
/**
|
||||
* `getType()` from pdf-lib's PDFField subclasses — usually one of
|
||||
* `getType()` from pdf-lib's PDFField subclasses - usually one of
|
||||
* `PDFTextField`, `PDFCheckBox`, `PDFDropdown`, `PDFRadioGroup`,
|
||||
* `PDFSignature`, `PDFButton`. Exposed verbatim so the UI can show
|
||||
* the admin what each field expects at the AcroForm layer.
|
||||
@@ -20,7 +20,7 @@ export interface AcroFormField {
|
||||
/**
|
||||
* Parses the AcroForm in `pdfBytes` and returns one descriptor per form
|
||||
* field. Returns an empty array when the PDF has no AcroForm at all
|
||||
* (i.e. a flat / non-fillable PDF). Never throws on a missing form —
|
||||
* (i.e. a flat / non-fillable PDF). Never throws on a missing form -
|
||||
* the caller treats "empty list" as a signal to nudge the operator
|
||||
* that their PDF isn't actually fillable.
|
||||
*/
|
||||
|
||||
@@ -42,7 +42,9 @@ export function BerthListReport({ title, subtitle, branding, generatedAt, config
|
||||
generatedAt={generatedAt}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
|
||||
<Text style={styles.sectionSubtitle} minPresenceAhead={80}>
|
||||
{cappedNotice}
|
||||
</Text>
|
||||
<ReportTable
|
||||
styles={styles}
|
||||
headers={columns.map((c) => c.label)}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function BrandedReportDocument({
|
||||
producer="Port Nimara CRM"
|
||||
>
|
||||
<Page size="A4" style={styles.page} wrap>
|
||||
{/* Header — logo + title + subtitle. Re-renders inside each
|
||||
{/* Header - logo + title + subtitle. Re-renders inside each
|
||||
page via `fixed` would duplicate the brand bar; instead we
|
||||
keep it as a non-fixed element so it lives at the very top
|
||||
of the first content page. Footer is `fixed` (bottom of
|
||||
|
||||
329
src/lib/pdf/reports/charts.tsx
Normal file
329
src/lib/pdf/reports/charts.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { Svg, G, Rect, Path, Line, Text as SvgText, Circle } from '@react-pdf/renderer';
|
||||
|
||||
/**
|
||||
* Hand-rolled SVG chart primitives used by the dashboard PDF report.
|
||||
*
|
||||
* @react-pdf/renderer ships native `<Svg>` + path primitives but no
|
||||
* higher-level chart library. Building these by hand (vs. server-side
|
||||
* rendering recharts to PNG via a headless browser) keeps the report
|
||||
* generator pure-Node, fast, and free of binary dependencies. Charts
|
||||
* are intentionally minimal - bars / segments / lines / labels - to
|
||||
* stay legible at A4 print scale.
|
||||
*
|
||||
* Coordinates use the chart's own viewBox so callers don't have to
|
||||
* think in points. The caller supplies a `width` (in points) and the
|
||||
* component draws inside that box; height scales proportionally.
|
||||
*/
|
||||
|
||||
const CHART_FONT = 9;
|
||||
const AXIS_COLOR = '#94a3b8'; // slate-400
|
||||
const GRID_COLOR = '#e2e8f0'; // slate-200
|
||||
const LABEL_COLOR = '#334155'; // slate-700
|
||||
|
||||
interface BarChartSeriesEntry {
|
||||
label: string;
|
||||
value: number;
|
||||
/** Optional second value rendered as a faint background bar - used
|
||||
* for "won vs total" style overlays. */
|
||||
secondaryValue?: number;
|
||||
}
|
||||
|
||||
interface HorizontalBarChartProps {
|
||||
data: BarChartSeriesEntry[];
|
||||
/** Box width in points. */
|
||||
width: number;
|
||||
/** Box height in points; defaults to a sensible value per row. */
|
||||
height?: number;
|
||||
/** Accent color for the primary bars; defaults to a CRM brand teal. */
|
||||
primaryColor?: string;
|
||||
/** Color for the secondary (background) bar. */
|
||||
secondaryColor?: string;
|
||||
/** Format the trailing per-bar number. Defaults to integer. */
|
||||
formatValue?: (n: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal bar chart. Each row labels its bar on the left, draws
|
||||
* the bar in the middle, and prints the value on the right. Used by
|
||||
* pipeline funnel + source-conversion chart variants.
|
||||
*/
|
||||
export function HorizontalBarChart({
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
primaryColor = '#0d9488',
|
||||
secondaryColor = '#cbd5e1',
|
||||
formatValue = (n) => String(Math.round(n)),
|
||||
}: HorizontalBarChartProps) {
|
||||
const rowHeight = 22;
|
||||
const labelWidth = Math.min(140, width * 0.32);
|
||||
const valueWidth = 50;
|
||||
const innerLeft = labelWidth + 6;
|
||||
const innerRight = width - valueWidth - 4;
|
||||
const innerWidth = innerRight - innerLeft;
|
||||
const max = Math.max(1, ...data.map((d) => Math.max(d.value, d.secondaryValue ?? 0)));
|
||||
const h = height ?? data.length * rowHeight + 8;
|
||||
|
||||
return (
|
||||
<Svg width={width} height={h}>
|
||||
{data.map((row, i) => {
|
||||
const y = i * rowHeight + 4;
|
||||
const barH = 14;
|
||||
const primaryW = (row.value / max) * innerWidth;
|
||||
const secondaryW = ((row.secondaryValue ?? 0) / max) * innerWidth;
|
||||
return (
|
||||
<G key={`${row.label}-${i}`}>
|
||||
<SvgText
|
||||
x={labelWidth}
|
||||
y={y + barH * 0.75}
|
||||
style={{ fontSize: CHART_FONT, fill: LABEL_COLOR }}
|
||||
textAnchor="end"
|
||||
>
|
||||
{row.label}
|
||||
</SvgText>
|
||||
{row.secondaryValue !== undefined ? (
|
||||
<Rect
|
||||
x={innerLeft}
|
||||
y={y}
|
||||
width={Math.max(1, secondaryW)}
|
||||
height={barH}
|
||||
fill={secondaryColor}
|
||||
/>
|
||||
) : null}
|
||||
<Rect
|
||||
x={innerLeft}
|
||||
y={y}
|
||||
width={Math.max(1, primaryW)}
|
||||
height={barH}
|
||||
fill={primaryColor}
|
||||
/>
|
||||
<SvgText
|
||||
x={width - 4}
|
||||
y={y + barH * 0.75}
|
||||
style={{ fontSize: CHART_FONT, fill: LABEL_COLOR }}
|
||||
textAnchor="end"
|
||||
>
|
||||
{formatValue(row.value)}
|
||||
</SvgText>
|
||||
</G>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface DonutChartProps {
|
||||
data: Array<{ label: string; value: number; color?: string }>;
|
||||
width: number;
|
||||
height?: number;
|
||||
/** Inner hole radius as a fraction of the outer (0.5 = donut, 0 = pie). */
|
||||
innerRatio?: number;
|
||||
/** Centre label override (e.g. total). */
|
||||
centerLabel?: string;
|
||||
/** Default palette cycled when entries don't carry their own colors. */
|
||||
palette?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_PALETTE = [
|
||||
'#0d9488', // teal
|
||||
'#0284c7', // sky
|
||||
'#7c3aed', // violet
|
||||
'#f97316', // orange
|
||||
'#dc2626', // red
|
||||
'#65a30d', // lime
|
||||
'#0891b2', // cyan
|
||||
'#7c2d12', // amber-deep
|
||||
];
|
||||
|
||||
/**
|
||||
* Donut chart. Renders each slice as an SVG arc path and prints a
|
||||
* legend below. Used by berth-status + lead-source-mix.
|
||||
*/
|
||||
export function DonutChart({
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
innerRatio = 0.6,
|
||||
centerLabel,
|
||||
palette = DEFAULT_PALETTE,
|
||||
}: DonutChartProps) {
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
const chartH = height ?? 160;
|
||||
const legendRowHeight = 14;
|
||||
const legendH = data.length * legendRowHeight + 8;
|
||||
const fullH = chartH + legendH;
|
||||
const cx = width / 2;
|
||||
const cy = chartH / 2;
|
||||
const outerR = Math.min(chartH, width) / 2 - 8;
|
||||
const innerR = outerR * innerRatio;
|
||||
|
||||
const START_ANGLE = -Math.PI / 2; // start at 12 o'clock
|
||||
// Pre-compute cumulative sweep per slice via reduce so we never reassign a
|
||||
// bound variable during render (react-hooks/immutability).
|
||||
const cumulativeSweeps = data.reduce<number[]>((acc, d) => {
|
||||
const sweep = total > 0 ? (d.value / total) * Math.PI * 2 : 0;
|
||||
const prev = acc.length === 0 ? 0 : (acc[acc.length - 1] ?? 0);
|
||||
acc.push(prev + sweep);
|
||||
return acc;
|
||||
}, []);
|
||||
const slices = data.map((d, i) => {
|
||||
const prevCumulative = i === 0 ? 0 : (cumulativeSweeps[i - 1] ?? 0);
|
||||
const startA = START_ANGLE + prevCumulative;
|
||||
const endA = START_ANGLE + (cumulativeSweeps[i] ?? prevCumulative);
|
||||
const sweep = endA - startA;
|
||||
const x1 = cx + Math.cos(startA) * outerR;
|
||||
const y1 = cy + Math.sin(startA) * outerR;
|
||||
const x2 = cx + Math.cos(endA) * outerR;
|
||||
const y2 = cy + Math.sin(endA) * outerR;
|
||||
const x3 = cx + Math.cos(endA) * innerR;
|
||||
const y3 = cy + Math.sin(endA) * innerR;
|
||||
const x4 = cx + Math.cos(startA) * innerR;
|
||||
const y4 = cy + Math.sin(startA) * innerR;
|
||||
const largeArc = sweep > Math.PI ? 1 : 0;
|
||||
// SVG path: M outer-start → arc → outer-end → L inner-end → arc-back → close
|
||||
const pathD =
|
||||
sweep <= 0
|
||||
? ''
|
||||
: `M ${x1.toFixed(2)} ${y1.toFixed(2)} ` +
|
||||
`A ${outerR.toFixed(2)} ${outerR.toFixed(2)} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} ` +
|
||||
`L ${x3.toFixed(2)} ${y3.toFixed(2)} ` +
|
||||
`A ${innerR.toFixed(2)} ${innerR.toFixed(2)} 0 ${largeArc} 0 ${x4.toFixed(2)} ${y4.toFixed(2)} ` +
|
||||
'Z';
|
||||
const color = d.color ?? palette[i % palette.length] ?? DEFAULT_PALETTE[0]!;
|
||||
return { ...d, pathD, color };
|
||||
});
|
||||
|
||||
return (
|
||||
<Svg width={width} height={fullH}>
|
||||
{/* Donut slices */}
|
||||
{slices.map((s, i) =>
|
||||
s.pathD ? <Path key={`slice-${i}`} d={s.pathD} fill={s.color} /> : null,
|
||||
)}
|
||||
{/* Centre label */}
|
||||
{centerLabel ? (
|
||||
<SvgText
|
||||
x={cx}
|
||||
y={cy + 4}
|
||||
style={{ fontSize: 14, fill: LABEL_COLOR, fontWeight: 600 }}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{centerLabel}
|
||||
</SvgText>
|
||||
) : null}
|
||||
{/* Legend rows below the donut */}
|
||||
{slices.map((s, i) => {
|
||||
const ly = chartH + i * legendRowHeight + 4;
|
||||
const pct = total > 0 ? ` · ${((s.value / total) * 100).toFixed(1)}%` : '';
|
||||
return (
|
||||
<G key={`legend-${i}`}>
|
||||
<Rect x={8} y={ly} width={9} height={9} fill={s.color} />
|
||||
<SvgText x={22} y={ly + 8} style={{ fontSize: CHART_FONT, fill: LABEL_COLOR }}>
|
||||
{`${s.label} · ${s.value}${pct}`}
|
||||
</SvgText>
|
||||
</G>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface LineChartProps {
|
||||
data: Array<{ label: string; value: number }>;
|
||||
width: number;
|
||||
height?: number;
|
||||
/** Format the y-axis tick labels (defaults to "value%" for 0-100). */
|
||||
yTickFormat?: (n: number) => string;
|
||||
/** Color of the line + filled area. */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple line chart with a faint filled area below the line and a
|
||||
* basic x-axis. Used by occupancy-timeline-chart.
|
||||
*/
|
||||
export function LineChart({
|
||||
data,
|
||||
width,
|
||||
height = 140,
|
||||
yTickFormat = (n) => `${n.toFixed(0)}%`,
|
||||
color = '#0d9488',
|
||||
}: LineChartProps) {
|
||||
const padLeft = 32;
|
||||
const padRight = 10;
|
||||
const padTop = 8;
|
||||
const padBottom = 24;
|
||||
const innerW = width - padLeft - padRight;
|
||||
const innerH = height - padTop - padBottom;
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
const max = Math.max(1, ...values);
|
||||
// Round max up to a "nice" number so the y-axis reads cleanly.
|
||||
const niceMax = Math.ceil(max / 10) * 10;
|
||||
|
||||
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
|
||||
const yFor = (v: number) => padTop + innerH - (v / niceMax) * innerH;
|
||||
const xFor = (i: number) => padLeft + i * xStep;
|
||||
|
||||
// Polyline path for the line itself + filled area below.
|
||||
const linePath = data
|
||||
.map((d, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i).toFixed(2)} ${yFor(d.value).toFixed(2)}`)
|
||||
.join(' ');
|
||||
const areaPath = data.length
|
||||
? linePath +
|
||||
` L ${xFor(data.length - 1).toFixed(2)} ${(padTop + innerH).toFixed(2)}` +
|
||||
` L ${padLeft.toFixed(2)} ${(padTop + innerH).toFixed(2)} Z`
|
||||
: '';
|
||||
|
||||
// X-axis labels: thin out to ~6 labels max so they don't overlap.
|
||||
const labelEvery = Math.max(1, Math.ceil(data.length / 6));
|
||||
|
||||
return (
|
||||
<Svg width={width} height={height}>
|
||||
{/* Gridlines + y-ticks (0 / 50 / 100 for percent scales) */}
|
||||
{[0, niceMax / 2, niceMax].map((v, i) => {
|
||||
const y = yFor(v);
|
||||
return (
|
||||
<G key={`grid-${i}`}>
|
||||
<Line
|
||||
x1={padLeft}
|
||||
y1={y}
|
||||
x2={width - padRight}
|
||||
y2={y}
|
||||
stroke={GRID_COLOR}
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<SvgText
|
||||
x={padLeft - 4}
|
||||
y={y + 3}
|
||||
style={{ fontSize: CHART_FONT - 1, fill: AXIS_COLOR }}
|
||||
textAnchor="end"
|
||||
>
|
||||
{yTickFormat(v)}
|
||||
</SvgText>
|
||||
</G>
|
||||
);
|
||||
})}
|
||||
{/* Area + line */}
|
||||
{areaPath ? <Path d={areaPath} fill={color} fillOpacity={0.12} /> : null}
|
||||
{linePath ? <Path d={linePath} stroke={color} strokeWidth={1.4} fill="none" /> : null}
|
||||
{/* Data points */}
|
||||
{data.map((d, i) => (
|
||||
<Circle key={`pt-${i}`} cx={xFor(i)} cy={yFor(d.value)} r={1.4} fill={color} />
|
||||
))}
|
||||
{/* X-axis labels */}
|
||||
{data.map((d, i) =>
|
||||
i % labelEvery === 0 || i === data.length - 1 ? (
|
||||
<SvgText
|
||||
key={`xl-${i}`}
|
||||
x={xFor(i)}
|
||||
y={height - 8}
|
||||
style={{ fontSize: CHART_FONT - 1, fill: AXIS_COLOR }}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{d.label}
|
||||
</SvgText>
|
||||
) : null,
|
||||
)}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,12 @@ export function ClientListReport({ title, subtitle, branding, generatedAt, confi
|
||||
generatedAt={generatedAt}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.sectionSubtitle}>{cappedNotice}</Text>
|
||||
{/* The intro line stays attached to the table header via the
|
||||
table's own `minPresenceAhead` - section heading + capacity
|
||||
notice + the first few rows always land on the same page. */}
|
||||
<Text style={styles.sectionSubtitle} minPresenceAhead={80}>
|
||||
{cappedNotice}
|
||||
</Text>
|
||||
<ReportTable
|
||||
styles={styles}
|
||||
headers={columns.map((c) => c.label)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text } from '@react-pdf/renderer';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import { BrandedReportDocument } from './branded-document';
|
||||
import { HorizontalBarChart, DonutChart, LineChart } from './charts';
|
||||
import { makeReportStyles } from './styles';
|
||||
import type { ReportBranding, DashboardReportConfig } from './types';
|
||||
|
||||
@@ -10,7 +11,7 @@ import type { ReportBranding, DashboardReportConfig } from './types';
|
||||
* Data shape consumed by the dashboard report. Caller (the route
|
||||
* handler) is responsible for fetching the dashboard service's
|
||||
* outputs and packing them into this struct. Keeps the React-PDF
|
||||
* tree pure — no DB calls inside the document tree.
|
||||
* tree pure - no DB calls inside the document tree.
|
||||
*/
|
||||
export interface DashboardReportData {
|
||||
kpis?: {
|
||||
@@ -42,6 +43,113 @@ export interface DashboardReportData {
|
||||
stage: string;
|
||||
lastContact: string | null;
|
||||
}>;
|
||||
/** Daily occupancy rate over the report window. Drives the
|
||||
* occupancy-timeline line chart. */
|
||||
occupancyTimeline?: Array<{ date: string; rate: number }>;
|
||||
/** Lead-source mix: count of NEW interests grouped by source for
|
||||
* the donut variant. Distinct from `sourceConversion` which is
|
||||
* cumulative + win-rate per source. */
|
||||
leadSourceMix?: Array<{ source: string; count: number }>;
|
||||
/** Pipeline value broken down by stage with the weighted forecast
|
||||
* (close-probability * gross) per stage. */
|
||||
pipelineValueBreakdown?: Array<{
|
||||
stage: string;
|
||||
gross: number;
|
||||
weighted: number;
|
||||
deals: number;
|
||||
currency: string;
|
||||
}>;
|
||||
/** % of interests that advance from each stage to the next, plus
|
||||
* the absolute count moved + dropped. */
|
||||
stageConversionRates?: Array<{
|
||||
fromStage: string;
|
||||
toStage: string;
|
||||
advanced: number;
|
||||
dropped: number;
|
||||
rate: number;
|
||||
}>;
|
||||
/** Median + mean days from new-enquiry to contract-signed; null
|
||||
* buckets mean not-enough-data. */
|
||||
avgSalesCycle?: {
|
||||
sampleSize: number;
|
||||
medianDays: number | null;
|
||||
meanDays: number | null;
|
||||
};
|
||||
/** Total weighted forecast snapshot - single dollar figure. */
|
||||
revenueForecast?: {
|
||||
grossValue: number;
|
||||
weightedValue: number;
|
||||
currency: string;
|
||||
};
|
||||
/** Inquiry-inbox triage breakdown over the report window. */
|
||||
inquiryInboxSummary?: Array<{
|
||||
kind: string;
|
||||
triageState: string;
|
||||
count: number;
|
||||
}>;
|
||||
/** Per-assignee reminder activity over the window. */
|
||||
remindersSummary?: Array<{
|
||||
assignee: string;
|
||||
open: number;
|
||||
completed: number;
|
||||
}>;
|
||||
/** Top berths ranked by active-interest count + heat tier. */
|
||||
berthDemandRanking?: Array<{
|
||||
mooringNumber: string;
|
||||
interestCount: number;
|
||||
tier: 'A' | 'B' | 'C' | 'D';
|
||||
}>;
|
||||
/** Pulse-tier histogram across active interests. */
|
||||
dealPulseDistribution?: Array<{ tier: string; count: number }>;
|
||||
/** Country-of-origin rollup for the active client book. */
|
||||
clientCountryDistribution?: Array<{ country: string; count: number }>;
|
||||
/** Compact recent-activity log for the print snapshot. */
|
||||
recentActivity?: Array<{
|
||||
when: string;
|
||||
actor: string | null;
|
||||
summary: string;
|
||||
}>;
|
||||
/** Clients added during the report window. */
|
||||
newClientsInPeriod?: Array<{
|
||||
name: string;
|
||||
createdAt: string;
|
||||
source: string | null;
|
||||
}>;
|
||||
/** Interests opened during the report window. */
|
||||
newInterestsInPeriod?: Array<{
|
||||
clientName: string;
|
||||
stage: string;
|
||||
source: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
/** Berths transitioned to Sold status during the report window. */
|
||||
berthsSoldInPeriod?: Array<{
|
||||
mooringNumber: string;
|
||||
soldAt: string;
|
||||
}>;
|
||||
/** Deposit payments received during the report window. */
|
||||
depositsReceivedInPeriod?: Array<{
|
||||
clientName: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
paidAt: string;
|
||||
}>;
|
||||
/** All signing documents marked completed during the report window. */
|
||||
signedDocumentsInPeriod?: Array<{
|
||||
type: string;
|
||||
title: string;
|
||||
signedAt: string;
|
||||
}>;
|
||||
/** Contracts marked completed during the report window. */
|
||||
contractsSignedInPeriod?: Array<{
|
||||
type: string;
|
||||
title: string;
|
||||
signedAt: string;
|
||||
}>;
|
||||
/** Stub markers for sections whose data resolvers ship later - the
|
||||
* PDF renders a "data resolver pending" placeholder so the
|
||||
* surface is discoverable even before the backend lands. */
|
||||
stubsPending?: string[];
|
||||
}
|
||||
|
||||
interface DashboardReportProps {
|
||||
@@ -61,7 +169,7 @@ interface DashboardReportProps {
|
||||
*
|
||||
* Chart-style widgets render as tables here (counts, percentages,
|
||||
* cohort breakdowns). The deliberate choice trades a chart's at-a-
|
||||
* glance shape for the actual numbers — a printed report is for
|
||||
* glance shape for the actual numbers - a printed report is for
|
||||
* later reference / sharing, not in-the-moment dashboard scanning,
|
||||
* and the table format is fully accessible to screen readers and
|
||||
* holds up if the PDF is OCR-scanned downstream.
|
||||
@@ -88,8 +196,14 @@ export function DashboardReport({
|
||||
subtitle={subtitle ?? `Dashboard summary${dateRangeLine ? ` · ${dateRangeLine}` : ''}`}
|
||||
generatedAt={generatedAt}
|
||||
>
|
||||
{/* Every section uses `wrap={false}` so the title + description
|
||||
+ table are guaranteed to land on the same page. If a section
|
||||
doesn't fit in the remaining space on the current page, the
|
||||
whole block moves to the next one - avoids the "section
|
||||
heading orphaned at the bottom of a page with the data on
|
||||
the next" layout the rep hit on 2026-05-22. */}
|
||||
{include('kpi_overview') && data.kpis ? (
|
||||
<View>
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Key metrics</Text>
|
||||
<View style={styles.kpiGrid}>
|
||||
<View style={styles.kpiCard}>
|
||||
@@ -119,7 +233,7 @@ export function DashboardReport({
|
||||
) : null}
|
||||
|
||||
{include('pipeline_funnel') && data.pipelineCounts ? (
|
||||
<View>
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Pipeline funnel</Text>
|
||||
<Text style={styles.sectionSubtitle}>Active interests grouped by pipeline stage.</Text>
|
||||
<SimpleTable
|
||||
@@ -132,7 +246,7 @@ export function DashboardReport({
|
||||
) : null}
|
||||
|
||||
{include('berth_status') && data.berthStatus ? (
|
||||
<View>
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Berth status</Text>
|
||||
<Text style={styles.sectionSubtitle}>Current distribution across the marina.</Text>
|
||||
<SimpleTable
|
||||
@@ -166,7 +280,7 @@ export function DashboardReport({
|
||||
) : null}
|
||||
|
||||
{include('source_conversion') && data.sourceConversion ? (
|
||||
<View>
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Source conversion</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Interest counts grouped by lead source, with win rate per source.
|
||||
@@ -186,8 +300,440 @@ export function DashboardReport({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('pipeline_funnel_chart') && data.pipelineCounts ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Pipeline funnel</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Active interests per pipeline stage. Bar length is proportional to count.
|
||||
</Text>
|
||||
<HorizontalBarChart
|
||||
width={500}
|
||||
data={data.pipelineCounts.map((row) => ({
|
||||
label: stageLabel(row.stage),
|
||||
value: row.count,
|
||||
}))}
|
||||
primaryColor={branding.primaryColor}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('berth_status_donut') && data.berthStatus ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Berth status</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Current distribution across the marina, donut variant.
|
||||
</Text>
|
||||
<DonutChart
|
||||
width={420}
|
||||
centerLabel={`${data.berthStatus.total}`}
|
||||
data={[
|
||||
{ label: 'Available', value: data.berthStatus.available, color: '#0d9488' },
|
||||
{ label: 'Under offer', value: data.berthStatus.underOffer, color: '#f59e0b' },
|
||||
{ label: 'Sold', value: data.berthStatus.sold, color: '#0284c7' },
|
||||
{ label: 'Maintenance', value: data.berthStatus.maintenance, color: '#94a3b8' },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('source_conversion_chart') && data.sourceConversion ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Source conversion</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Win rate per lead source. Faint bar shows total volume, primary bar shows won deals.
|
||||
</Text>
|
||||
<HorizontalBarChart
|
||||
width={500}
|
||||
data={data.sourceConversion.map((row) => ({
|
||||
label: row.source,
|
||||
value: row.won,
|
||||
secondaryValue: row.total,
|
||||
}))}
|
||||
primaryColor={branding.primaryColor}
|
||||
formatValue={(n) => `${n}`}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('lead_source_donut') && data.leadSourceMix && data.leadSourceMix.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Lead source mix</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Share of new interests by lead source over the report window.
|
||||
</Text>
|
||||
<DonutChart
|
||||
width={420}
|
||||
centerLabel={`${data.leadSourceMix.reduce((s, r) => s + r.count, 0)}`}
|
||||
data={data.leadSourceMix.map((r) => ({ label: r.source, value: r.count }))}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('occupancy_timeline_chart') &&
|
||||
data.occupancyTimeline &&
|
||||
data.occupancyTimeline.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Occupancy timeline</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Daily berth occupancy rate over the report window.
|
||||
</Text>
|
||||
<LineChart
|
||||
width={500}
|
||||
data={data.occupancyTimeline.map((p) => ({
|
||||
label: new Date(p.date).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
}),
|
||||
value: p.rate,
|
||||
}))}
|
||||
color={branding.primaryColor}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('pipeline_value_breakdown') &&
|
||||
data.pipelineValueBreakdown &&
|
||||
data.pipelineValueBreakdown.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Pipeline value breakdown</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Gross + weighted pipeline value per stage, weighted by close probability.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Stage', 'Deals', 'Gross', 'Weighted']}
|
||||
widths={[40, 15, 22, 23]}
|
||||
rows={data.pipelineValueBreakdown.map((row) => [
|
||||
stageLabel(row.stage),
|
||||
String(row.deals),
|
||||
formatCurrency(String(row.gross), row.currency, { maxFractionDigits: 0 }),
|
||||
formatCurrency(String(row.weighted), row.currency, { maxFractionDigits: 0 }),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('stage_conversion_rates') &&
|
||||
data.stageConversionRates &&
|
||||
data.stageConversionRates.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Stage conversion rates</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
% of interests that advance from each pipeline stage to the next.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['From → To', 'Advanced', 'Dropped', 'Rate']}
|
||||
widths={[42, 18, 18, 22]}
|
||||
rows={data.stageConversionRates.map((row) => [
|
||||
`${stageLabel(row.fromStage)} → ${stageLabel(row.toStage)}`,
|
||||
String(row.advanced),
|
||||
String(row.dropped),
|
||||
`${(row.rate * 100).toFixed(1)}%`,
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('reminders_summary') && data.remindersSummary && data.remindersSummary.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Reminders summary</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Open + completed reminders per assignee over the report window.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Assignee', 'Open', 'Completed']}
|
||||
widths={[60, 20, 20]}
|
||||
rows={data.remindersSummary.map((r) => [
|
||||
r.assignee,
|
||||
String(r.open),
|
||||
String(r.completed),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('inquiry_inbox_summary') &&
|
||||
data.inquiryInboxSummary &&
|
||||
data.inquiryInboxSummary.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Inbound inquiry summary</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Public-site submissions received during the report window, grouped by triage state.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Kind', 'Triage state', 'Count']}
|
||||
widths={[40, 40, 20]}
|
||||
rows={data.inquiryInboxSummary.map((r) => [r.kind, r.triageState, String(r.count)])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('revenue_forecast') && data.revenueForecast ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Revenue forecast snapshot</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Total pipeline value, weighted by close probability per stage.
|
||||
</Text>
|
||||
<View style={styles.kpiGrid}>
|
||||
<View style={styles.kpiCard}>
|
||||
<Text style={styles.kpiLabel}>Gross</Text>
|
||||
<Text style={styles.kpiValue}>
|
||||
{formatCurrency(
|
||||
String(data.revenueForecast.grossValue),
|
||||
data.revenueForecast.currency,
|
||||
{ maxFractionDigits: 0 },
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.kpiSubvalue}>Sum of primary-berth prices, active deals</Text>
|
||||
</View>
|
||||
<View style={styles.kpiCard}>
|
||||
<Text style={styles.kpiLabel}>Weighted forecast</Text>
|
||||
<Text style={styles.kpiValue}>
|
||||
{formatCurrency(
|
||||
String(data.revenueForecast.weightedValue),
|
||||
data.revenueForecast.currency,
|
||||
{ maxFractionDigits: 0 },
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.kpiSubvalue}>Gross x close-probability per stage</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('avg_sales_cycle') && data.avgSalesCycle ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Average sales cycle</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Days from new-enquiry to contract-signed across {data.avgSalesCycle.sampleSize} closed
|
||||
deals.
|
||||
</Text>
|
||||
<View style={styles.kpiGrid}>
|
||||
<View style={styles.kpiCard}>
|
||||
<Text style={styles.kpiLabel}>Median</Text>
|
||||
<Text style={styles.kpiValue}>
|
||||
{data.avgSalesCycle.medianDays !== null
|
||||
? `${data.avgSalesCycle.medianDays} d`
|
||||
: 'n/a'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.kpiCard}>
|
||||
<Text style={styles.kpiLabel}>Mean</Text>
|
||||
<Text style={styles.kpiValue}>
|
||||
{data.avgSalesCycle.meanDays !== null ? `${data.avgSalesCycle.meanDays} d` : 'n/a'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('berth_demand_ranking') &&
|
||||
data.berthDemandRanking &&
|
||||
data.berthDemandRanking.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Berth demand ranking</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Top berths by active-interest count + heat tier (A = strongest signal).
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Mooring', 'Active interests', 'Tier']}
|
||||
widths={[40, 40, 20]}
|
||||
rows={data.berthDemandRanking.map((row) => [
|
||||
row.mooringNumber,
|
||||
String(row.interestCount),
|
||||
row.tier,
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('deal_pulse_distribution') &&
|
||||
data.dealPulseDistribution &&
|
||||
data.dealPulseDistribution.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Deal pulse distribution</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Counts of active interests in each pulse tier (hot / warm / cool / cold).
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Tier', 'Count']}
|
||||
widths={[70, 30]}
|
||||
rows={data.dealPulseDistribution.map((row) => [row.tier, String(row.count)])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('client_country_distribution') &&
|
||||
data.clientCountryDistribution &&
|
||||
data.clientCountryDistribution.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Client country distribution</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Active-client counts grouped by nationality (ISO 3166-1 alpha-2).
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Country', 'Clients']}
|
||||
widths={[70, 30]}
|
||||
rows={data.clientCountryDistribution.map((row) => [row.country, String(row.count)])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('recent_activity') && data.recentActivity && data.recentActivity.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Recent activity</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Last entries from the audit log, compact snapshot.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['When', 'Who', 'Summary']}
|
||||
widths={[18, 22, 60]}
|
||||
rows={data.recentActivity.map((row) => [
|
||||
new Date(row.when).toLocaleString('en-GB'),
|
||||
row.actor ?? 'system',
|
||||
row.summary,
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('new_clients_period') &&
|
||||
data.newClientsInPeriod &&
|
||||
data.newClientsInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>New clients (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Clients added during the report window with their lead source. Capped at 50 rows; full
|
||||
list lives in the client export.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Client', 'Source', 'Added']}
|
||||
widths={[55, 25, 20]}
|
||||
rows={data.newClientsInPeriod.map((r) => [
|
||||
r.name,
|
||||
r.source ?? '-',
|
||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('new_interests_period') &&
|
||||
data.newInterestsInPeriod &&
|
||||
data.newInterestsInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>New interests (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Interests opened during the report window, with the stage they currently sit at and
|
||||
their lead source.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Client', 'Stage', 'Source', 'Opened']}
|
||||
widths={[40, 22, 18, 20]}
|
||||
rows={data.newInterestsInPeriod.map((r) => [
|
||||
r.clientName,
|
||||
stageLabel(r.stage),
|
||||
r.source ?? '-',
|
||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('berths_sold_period') &&
|
||||
data.berthsSoldInPeriod &&
|
||||
data.berthsSoldInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Berths sold (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Berths transitioned to Sold status during the report window, resolved from the audit
|
||||
log.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Mooring', 'Sold on']}
|
||||
widths={[50, 50]}
|
||||
rows={data.berthsSoldInPeriod.map((r) => [
|
||||
r.mooringNumber,
|
||||
new Date(r.soldAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('signed_documents_period') &&
|
||||
data.signedDocumentsInPeriod &&
|
||||
data.signedDocumentsInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Documents signed (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
EOIs, reservations, and contracts marked completed during the report window.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Type', 'Title', 'Signed on']}
|
||||
widths={[20, 55, 25]}
|
||||
rows={data.signedDocumentsInPeriod.map((r) => [
|
||||
r.type,
|
||||
r.title,
|
||||
new Date(r.signedAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('contracts_signed_period') &&
|
||||
data.contractsSignedInPeriod &&
|
||||
data.contractsSignedInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Contracts signed (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Contract documents that completed signing during the report window.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Title', 'Signed on']}
|
||||
widths={[75, 25]}
|
||||
rows={data.contractsSignedInPeriod.map((r) => [
|
||||
r.title,
|
||||
new Date(r.signedAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('deposits_received_period') &&
|
||||
data.depositsReceivedInPeriod &&
|
||||
data.depositsReceivedInPeriod.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Deposits received (in period)</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Deposit payments received during the report window, with client + $ amount.
|
||||
</Text>
|
||||
<SimpleTable
|
||||
styles={styles}
|
||||
headers={['Client', 'Amount', 'Date']}
|
||||
widths={[55, 25, 20]}
|
||||
rows={data.depositsReceivedInPeriod.map((r) => [
|
||||
r.clientName,
|
||||
formatCurrency(String(r.amount), r.currency, { maxFractionDigits: 0 }),
|
||||
new Date(r.paidAt).toLocaleDateString('en-GB'),
|
||||
])}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{include('hot_deals') && data.hotDeals && data.hotDeals.length > 0 ? (
|
||||
<View>
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Hot deals</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
Top active interests, ranked by pipeline stage with most-recent activity as tiebreaker.
|
||||
@@ -205,12 +751,32 @@ export function DashboardReport({
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Pending-resolver placeholder. Lets the user see that a
|
||||
selected widget IS recognised by the renderer even when its
|
||||
data resolver hasn't shipped yet - keeps the surface
|
||||
discoverable instead of silently dropping the request. */}
|
||||
{data.stubsPending && data.stubsPending.length > 0 ? (
|
||||
<View wrap={false}>
|
||||
<Text style={styles.sectionTitle}>Coming soon</Text>
|
||||
<Text style={styles.sectionSubtitle}>
|
||||
These sections are wired into the export dialog but their data resolvers ship in the
|
||||
next iteration. The choice persists, so the next report run will include them
|
||||
automatically:
|
||||
</Text>
|
||||
{data.stubsPending.map((id) => (
|
||||
<Text key={id} style={styles.kpiSubvalue}>
|
||||
· {id}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</BrandedReportDocument>
|
||||
);
|
||||
}
|
||||
|
||||
function pct(n: number, total: number): string {
|
||||
return total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '—';
|
||||
return total > 0 ? `${((n / total) * 100).toFixed(1)}%` : '-';
|
||||
}
|
||||
|
||||
interface SimpleTableProps {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user