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

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