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:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -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.
*/

View File

@@ -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;

View File

@@ -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(

View File

@@ -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.
*

View File

@@ -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.

View File

@@ -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,

View File

@@ -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.

View File

@@ -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.
*

View File

@@ -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',
};

View File

@@ -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.
};

View File

@@ -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, {

View File

@@ -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.
--

View File

@@ -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 -
-- 612 events/year doesn't justify a `trips` table + CRUD UI. The
-- autocomplete on the expense form keeps spellings consistent so the
-- group-by works.

View File

@@ -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

View File

@@ -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:

View File

@@ -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).
--

View File

@@ -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

View File

@@ -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"

View File

@@ -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`).

View File

@@ -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

View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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).',

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.
--

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"

View File

@@ -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.

View File

@@ -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:

View File

@@ -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").

View File

@@ -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).

View File

@@ -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'),

View File

@@ -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.
*/

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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 612/year and full event-management
* un-normalized - events are 612/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`

View File

@@ -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`).

View File

@@ -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' }),

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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')

View File

@@ -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'`.
*/

View File

@@ -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.

View File

@@ -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'),

View File

@@ -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',

View File

@@ -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')

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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 });

View File

@@ -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.
*

View File

@@ -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({

View File

@@ -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();

View File

@@ -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`.
*/

View File

@@ -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.
*

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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(

View File

@@ -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;

View File

@@ -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('/') ||

View File

@@ -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',
},
};

View File

@@ -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;

View File

@@ -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&apos;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' }}>

View File

@@ -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&apos;t sent to a third-party
Signing happens directly inside our website - your data isn&apos;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&apos;s still awaiting your
signature. If you&apos;ve already signed, please disregard this message it can take a few
signature. If you&apos;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 {

View File

@@ -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 {

View File

@@ -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&apos;s what&apos;s waiting for you <strong>{totalUnread}</strong> item
Here&apos;s what&apos;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}`),
'',

View File

@@ -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&apos;s our pleasure to invite you to the {portName} client portal your private space to
It&apos;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&apos;t request this, you may safely ignore this message your existing password
If you didn&apos;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`,

View File

@@ -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,

View 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 reps 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);
}

View File

@@ -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`. */

View File

@@ -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.',
});
}
});

View File

@@ -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.

View File

@@ -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>;

View File

@@ -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 });

View File

@@ -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 §§56 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.

View File

@@ -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>([

View File

@@ -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

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 ?? ''));

View File

@@ -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.
*/

View File

@@ -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)}

View File

@@ -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

View 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>
);
}

View File

@@ -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)}

View File

@@ -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