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:
@@ -23,7 +23,7 @@ const connectionString = process.env.DATABASE_URL!;
|
||||
// admin clicking around can saturate 20 slots. Production stays at the
|
||||
// conservative 20 so we don't hammer postgres in a multi-replica deploy.
|
||||
//
|
||||
// 60 was too aggressive locally — postgres + the drizzle logger creates
|
||||
// 60 was too aggressive locally - postgres + the drizzle logger creates
|
||||
// massive log volume that backed up node's stderr, blocking the event
|
||||
// loop on otherwise-cheap requests. 30 is a middle ground that holds
|
||||
// during clients-page fanout without log-storm.
|
||||
@@ -38,7 +38,7 @@ const POOL_MAX = process.env.NODE_ENV === 'development' ? 30 : 20;
|
||||
// - connect_timeout: 5s so failures surface fast instead of stalling
|
||||
// requests for 10s before erroring.
|
||||
// - max_lifetime: 10min so connections recycle before stale sockets
|
||||
// accumulate. Was 30min — too long for the Docker socket-drop pattern.
|
||||
// accumulate. Was 30min - too long for the Docker socket-drop pattern.
|
||||
// - onnotice: surfaces postgres NOTICE/WARNING messages that we'd
|
||||
// otherwise miss (extension warnings, deprecation hints).
|
||||
const queryClient = postgres(connectionString, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Audit-final v2 fix: document_sends FKs default to NO ACTION which means
|
||||
-- a hard-delete of a referenced client/interest/berth/brochure either
|
||||
-- silently blocks the parent delete OR (if a future cascade path is added)
|
||||
-- nukes the send-out audit row. The audit trail must outlast its source —
|
||||
-- nukes the send-out audit row. The audit trail must outlast its source -
|
||||
-- recipient_email + document_kind + body_markdown + from_address are
|
||||
-- already denormalized onto the row for exactly this purpose.
|
||||
--
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Free-text trip / event label so reps can group expenses by yacht show
|
||||
-- or business trip (e.g. "Palm Beach 2026"). Un-normalized on purpose —
|
||||
-- or business trip (e.g. "Palm Beach 2026"). Un-normalized on purpose -
|
||||
-- 6–12 events/year doesn't justify a `trips` table + CRUD UI. The
|
||||
-- autocomplete on the expense form keeps spellings consistent so the
|
||||
-- group-by works.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- every existing row in `roles.permissions`. The schema (RolePermissions
|
||||
-- in src/lib/db/schema/users.ts) added these keys to close the silent-403
|
||||
-- traps on PATCH /api/v1/documents/[id], /cancel, /remind, /watchers, and
|
||||
-- PATCH /api/v1/files/[id] — each used a permission key that did not exist
|
||||
-- PATCH /api/v1/files/[id] - each used a permission key that did not exist
|
||||
-- in the schema, so withPermission()'s `resourcePerms[action]` returned
|
||||
-- undefined and 403'd every non-superadmin call.
|
||||
--
|
||||
@@ -15,7 +15,7 @@
|
||||
-- against a partial run.
|
||||
--
|
||||
-- Note: per-port overrides live in `port_role_overrides.permission_overrides`
|
||||
-- and are PARTIAL — they only contain the keys a port flipped from the
|
||||
-- and are PARTIAL - they only contain the keys a port flipped from the
|
||||
-- base role. The deepMerge resolver fills in `documents.edit` from the
|
||||
-- base role for any port that didn't override it, so we deliberately do
|
||||
-- NOT touch `port_role_overrides` here. Backfilling there would synthesize
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Audit-final v3 — wire the FK columns currently exposed via Drizzle
|
||||
-- Audit-final v3 - wire the FK columns currently exposed via Drizzle
|
||||
-- relations() but missing actual Postgres constraints. relations() only
|
||||
-- configures relational query JOINs; it does NOT install constraints, so
|
||||
-- a service that writes interest_id='nonexistent' faces no DB rejection
|
||||
@@ -7,7 +7,7 @@
|
||||
-- All adds are idempotent (DO blocks swallow duplicate_object) and use
|
||||
-- the NOT VALID + VALIDATE pattern so the brief table-lock phase is
|
||||
-- decoupled from the slow row-scan validation. If validation fails for
|
||||
-- a constraint the migration aborts before later constraints land — a
|
||||
-- a constraint the migration aborts before later constraints land - a
|
||||
-- prod operator can clean the dirty row(s) and re-run.
|
||||
--
|
||||
-- Cascade rule:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Smart-archive feature — add columns that capture WHO archived a client,
|
||||
-- Smart-archive feature - add columns that capture WHO archived a client,
|
||||
-- WHY, and WHAT decisions they made along the way (for the restore
|
||||
-- wizard to attempt reversal).
|
||||
--
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Reconcile the system_settings unique-index drift surfaced in the
|
||||
-- final-deferred audit. The Drizzle schema declares a uniqueIndex on
|
||||
-- (key, port_id), but Postgres treats NULL values as distinct by default.
|
||||
-- That means two rows with `(same_key, NULL)` would BOTH be allowed —
|
||||
-- That means two rows with `(same_key, NULL)` would BOTH be allowed -
|
||||
-- a global-setting collision the index claims to prevent.
|
||||
--
|
||||
-- This was not just theoretical: the dev DB had 60+ duplicate
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
-- (Clients/Companies/Yachts roots + per-entity subfolders) and the
|
||||
-- folder_id pointer to files. Backfill (ensureSystemRoots + per-
|
||||
-- entity subfolders + files.folder_id from entity FKs) runs as a
|
||||
-- separate deploy step — see scripts/backfill-document-folders.ts.
|
||||
-- Idempotent — safe to re-run.
|
||||
-- separate deploy step - see scripts/backfill-document-folders.ts.
|
||||
-- Idempotent - safe to re-run.
|
||||
|
||||
-- ─── document_folders: lifecycle columns ──────────────────────────────────
|
||||
ALTER TABLE "document_folders"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Prod-readiness audit 2026-05-11 follow-ups (A5 + Audit-17 G-I4 + perf).
|
||||
-- Fully idempotent — safe to re-run.
|
||||
-- Fully idempotent - safe to re-run.
|
||||
--
|
||||
-- IMPORTANT: This migration creates indexes CONCURRENTLY, which Postgres
|
||||
-- forbids inside a transaction block. When applying via `psql`, do NOT
|
||||
@@ -75,7 +75,7 @@ END $$;
|
||||
-- so the rebuild rides the new indexes.
|
||||
--
|
||||
-- CONCURRENTLY avoids the ShareLock that blocks writes during a normal
|
||||
-- CREATE INDEX. It can fail mid-build — IF NOT EXISTS skips on re-run,
|
||||
-- CREATE INDEX. It can fail mid-build - IF NOT EXISTS skips on re-run,
|
||||
-- but a failed/invalid index from a prior attempt needs to be dropped
|
||||
-- before this migration succeeds (check `pg_indexes` + `pg_index.indisvalid`).
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
-- 0053 — Measurement units (entry-unit tracking + interest dual-store)
|
||||
-- 0053 - Measurement units (entry-unit tracking + interest dual-store)
|
||||
--
|
||||
-- Problem: dimensions on interests/yachts/berths are stored in ft OR m, but
|
||||
-- the CRM blindly converts between them on display. When a rep edits the
|
||||
-- converted side, the original entered value drifts by floating-point error
|
||||
-- (e.g. 18.29m → 60.039ft → back to 18.297m).
|
||||
--
|
||||
-- Fix: every dimension gets two pieces of info — a value column per unit
|
||||
-- Fix: every dimension gets two pieces of info - a value column per unit
|
||||
-- (so we can render the user's literal entry verbatim) AND a small
|
||||
-- discriminator column (`*_unit`) saying which side the user originally
|
||||
-- typed in. The form prefers the entered unit when displaying; the other
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
-- Constraints (enforced application-side AND in SQL):
|
||||
-- - 2..30 characters
|
||||
-- - lowercase letters, digits, dot, underscore, hyphen
|
||||
-- - case-insensitive uniqueness per install (no per-port scoping —
|
||||
-- - case-insensitive uniqueness per install (no per-port scoping -
|
||||
-- reps move between ports and a global username keeps URLs stable)
|
||||
--
|
||||
-- The column is nullable; existing users keep email-only sign-in until they
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
-- |> apply port_role_overrides for that port
|
||||
-- |> apply user_permission_overrides for (user, port)
|
||||
--
|
||||
-- A user override entry is OPTIONAL — most users will never have one.
|
||||
-- A user override entry is OPTIONAL - most users will never have one.
|
||||
-- When present, the JSONB blob is a Partial<RolePermissions> map where any
|
||||
-- explicitly-set leaf wins over the inherited value (true forces grant,
|
||||
-- false forces deny, missing → inherit).
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
-- confused admin can spam the email-change endpoint to generate
|
||||
-- multiple pending tokens, each emailing the operator's inbox.
|
||||
--
|
||||
-- 3. user_port_roles.userId previously had no FK either — see data-model
|
||||
-- 3. user_port_roles.userId previously had no FK either - see data-model
|
||||
-- H1. Add the same cascade.
|
||||
--
|
||||
-- Each statement is wrapped in DO blocks so the migration is replayable
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
--
|
||||
-- Built with CREATE INDEX CONCURRENTLY so the index build doesn't lock
|
||||
-- the table for new writes. That means each statement must run OUTSIDE
|
||||
-- a transaction — the custom `scripts/db-migrate.ts` runner detects
|
||||
-- a transaction - the custom `scripts/db-migrate.ts` runner detects
|
||||
-- CONCURRENTLY and runs the statement standalone.
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_clients_fulltext
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
-- Rep can author a short note in the upload-for-signing dialog that
|
||||
-- gets inserted above the CTA in every signing-invitation email for
|
||||
-- this document. Plain text (XSS-escaped by the email renderer).
|
||||
-- Null means "no custom message — use the template default".
|
||||
-- Null means "no custom message - use the template default".
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN IF NOT EXISTS invitation_message text;
|
||||
|
||||
@@ -21,7 +21,7 @@ ALTER TABLE interests
|
||||
CREATE INDEX IF NOT EXISTS idx_interests_assigned_to ON interests (assigned_to);
|
||||
|
||||
-- ─── stage value migration (collapse Sent/Signed pairs) ────────────────────
|
||||
-- Dummy-data only — destructive UPDATE is safe.
|
||||
-- Dummy-data only - destructive UPDATE is safe.
|
||||
|
||||
UPDATE interests SET pipeline_stage = 'enquiry'
|
||||
WHERE pipeline_stage IN ('open', 'details_sent', 'in_communication');
|
||||
@@ -86,7 +86,7 @@ INSERT INTO qualification_criteria (port_id, key, label, description, enabled, d
|
||||
FROM ports p
|
||||
ON CONFLICT (port_id, key) DO NOTHING;
|
||||
|
||||
-- These are built but disabled — admins enable per port when relevant.
|
||||
-- These are built but disabled - admins enable per port when relevant.
|
||||
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
|
||||
SELECT p.id, 'signatory', 'Buyer signatory confirmed',
|
||||
'We know who is authorized to sign the EOI on the buyer side (owner / lawyer / company rep).',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
-- 0065_predeploy_schema.sql
|
||||
-- Pre-deploy schema additions per PRE-DEPLOY-PLAN § 1.4:
|
||||
-- 1. berths.archived_at — soft-delete column + partial index, filter for the
|
||||
-- 1. berths.archived_at - soft-delete column + partial index, filter for the
|
||||
-- public berth feed so retired berths stop showing up on the marketing site.
|
||||
-- 2. clients.source_inquiry_id — preserves the linkage from a website inquiry
|
||||
-- 2. clients.source_inquiry_id - preserves the linkage from a website inquiry
|
||||
-- to the client that came out of the "Convert to client" triage step
|
||||
-- (P-4.5). Drives the conversion-funnel-by-source chart.
|
||||
-- 3. email_bounces — bounce-monitoring storage; the IMAP poller writes here
|
||||
-- 3. email_bounces - bounce-monitoring storage; the IMAP poller writes here
|
||||
-- and document_sends / notifications / email_threads consumers can join
|
||||
-- this in to surface "your message bounced" indicators.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
-- The NocoDB importer historically wrote 'auto' to indicate "no override
|
||||
-- in effect" for legacy data. The post-refactor code uses NULL for that
|
||||
-- sentinel and 'manual' / 'automated' for the new states. Mixed values
|
||||
-- pollute the reconcile-queue predicate and the Manual chip — neither
|
||||
-- pollute the reconcile-queue predicate and the Manual chip - neither
|
||||
-- path treats 'auto' specially today, but normalizing closes the gap
|
||||
-- once and for all and keeps the column to a 3-state enum.
|
||||
UPDATE berths
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- the timeline of notes carries the stage they were made at. Read by the
|
||||
-- NotesList UI to render a per-note stage chip.
|
||||
--
|
||||
-- Pre-2026-05-15 rows stay null — backfill from audit_logs would be
|
||||
-- Pre-2026-05-15 rows stay null - backfill from audit_logs would be
|
||||
-- inaccurate (the audit row only captures the AFTER-stage on stage moves,
|
||||
-- not the at-rest state when a note was inserted). New notes carry the
|
||||
-- stamp going forward.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
--
|
||||
-- Without an explicit action Postgres defaults to NO ACTION, so a hard-
|
||||
-- delete of a parent (client, port, berth, file, document signer) is
|
||||
-- blocked at FK check time — sometimes intentional, often surprising.
|
||||
-- blocked at FK check time - sometimes intentional, often surprising.
|
||||
-- Each FK below now declares whether parent deletion is RESTRICT (block,
|
||||
-- force the operator to archive the parent or unlink the children first)
|
||||
-- or SET NULL (allow the deletion, null the FK so child rows stay around
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- columns used by `buildListQuery` (src/lib/db/query-builder.ts:67).
|
||||
--
|
||||
-- Without these, every `ILIKE '%term%'` predicate sequential-scans
|
||||
-- the entire table. The fix isn't to rewrite the SQL — Postgres will
|
||||
-- the entire table. The fix isn't to rewrite the SQL - Postgres will
|
||||
-- transparently use a `gin_trgm_ops` index for ILIKE patterns once
|
||||
-- one exists on the column.
|
||||
--
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
-- Phase 4 — Reminders expansion (POST-AUDIT-SPEC §2 + MASTER-PLAN §D).
|
||||
-- Phase 4 - Reminders expansion (POST-AUDIT-SPEC §2 + MASTER-PLAN §D).
|
||||
--
|
||||
-- Adds:
|
||||
-- 1. interests.reminder_note — cadence note surfaced in notification body + inbox row.
|
||||
-- 2. reminders.yacht_id — fourth supported entity link (was: client/interest/berth).
|
||||
-- 3. reminders.fired_at — worker idempotency; set once the firing notification is
|
||||
-- 1. interests.reminder_note - cadence note surfaced in notification body + inbox row.
|
||||
-- 2. reminders.yacht_id - fourth supported entity link (was: client/interest/berth).
|
||||
-- 3. reminders.fired_at - worker idempotency; set once the firing notification is
|
||||
-- created so a parallel worker can't double-fire.
|
||||
-- 4. user_profiles.preferences gains `digest_time_of_day` (JSONB key; no DDL).
|
||||
--
|
||||
-- The existing reminders table already carries title/note/dueAt/priority/assignedTo/
|
||||
-- snoozedUntil/googleCalendarEventId — those columns are reused unchanged. No new
|
||||
-- snoozedUntil/googleCalendarEventId - those columns are reused unchanged. No new
|
||||
-- table; standalone tasks set client_id/interest_id/berth_id/yacht_id all NULL.
|
||||
|
||||
ALTER TABLE interests
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase 3 — EOI field override foundation (POST-AUDIT-SPEC §1 + MASTER-PLAN §C).
|
||||
-- Phase 3 - EOI field override foundation (POST-AUDIT-SPEC §1 + MASTER-PLAN §C).
|
||||
--
|
||||
-- This migration is foundation-only. The EOI generate-and-sign endpoint
|
||||
-- + the dialog UI extensions land in subsequent sub-sessions (3a-3d).
|
||||
@@ -9,7 +9,7 @@
|
||||
-- 3. yachts.{source, source_document_id}
|
||||
-- 4. documents.override_* columns mirroring the AcroForm field map
|
||||
--
|
||||
-- audit_actions is a free-text column (text, not enum) — new action verbs
|
||||
-- audit_actions is a free-text column (text, not enum) - new action verbs
|
||||
-- 'eoi_field_override', 'promote_to_primary', 'eoi_spawn_yacht' don't
|
||||
-- need DDL; they just appear in inserted rows.
|
||||
|
||||
@@ -18,7 +18,7 @@ ALTER TABLE client_contacts
|
||||
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual',
|
||||
ADD COLUMN IF NOT EXISTS source_document_id text;
|
||||
|
||||
-- Add FK only if it doesn't exist yet. ON DELETE SET NULL — keep the
|
||||
-- Add FK only if it doesn't exist yet. ON DELETE SET NULL - keep the
|
||||
-- contact row around even if the originating document is deleted.
|
||||
DO $$
|
||||
BEGIN
|
||||
@@ -84,7 +84,7 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ─── documents — per-document overrides ────────────────────────────────
|
||||
-- ─── documents - per-document overrides ────────────────────────────────
|
||||
-- These columns mirror the AcroForm field set per
|
||||
-- docs/eoi-documenso-field-mapping.md. When NULL, the canonical
|
||||
-- client/yacht values flow through unchanged. When set, the EOI uses
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase 6 — IMAP bounce-to-interest linking foundation
|
||||
-- Phase 6 - IMAP bounce-to-interest linking foundation
|
||||
-- (POST-AUDIT-SPEC §14.9 / MASTER-PLAN §F).
|
||||
--
|
||||
-- Schema-only. Parser library + cron worker land in 6b/6c.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
-- This migration adds:
|
||||
-- 1. A nullable `recipient_email` column to document_events.
|
||||
-- 2. A partial unique index on (document_id, recipient_email, event_type)
|
||||
-- where recipient_email IS NOT NULL — so per-recipient events dedup
|
||||
-- where recipient_email IS NOT NULL - so per-recipient events dedup
|
||||
-- independently while legacy events without recipient context fall
|
||||
-- through to the existing signature_hash dedup.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase 4b — email open tracking via a 1×1 pixel endpoint.
|
||||
-- Phase 4b - email open tracking via a 1×1 pixel endpoint.
|
||||
-- Adds a per-send open log + cached aggregates on document_sends.
|
||||
|
||||
ALTER TABLE "document_sends"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase 4c — tracked redirect links for email click-through tracking.
|
||||
-- Phase 4c - tracked redirect links for email click-through tracking.
|
||||
-- A short URL at /q/<slug> redirects to the target and records the
|
||||
-- click against the originating send. Cross-posted to Umami as a
|
||||
-- `link-clicked` event.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
-- in InterestDocumentsTab.
|
||||
--
|
||||
-- 3. Soft FK (`ON DELETE SET NULL`) so a hard-deleted interest doesn't
|
||||
-- orphan the file — the audit trail stays intact and the file remains
|
||||
-- orphan the file - the audit trail stays intact and the file remains
|
||||
-- findable under the parent client folder.
|
||||
--
|
||||
-- Apply in dev:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- 2026-05-21: interest_field_history table.
|
||||
--
|
||||
-- Captures field-level overrides — every time a value on an interest
|
||||
-- Captures field-level overrides - every time a value on an interest
|
||||
-- or its linked client changes via a supplemental-info form (or any
|
||||
-- future channel that explicitly records overrides), a row lands here
|
||||
-- with the old + new values plus source attribution.
|
||||
@@ -8,7 +8,7 @@
|
||||
-- Why a separate table vs piggybacking on `audit_logs`:
|
||||
-- - audit_logs is a fire-hose of every CRUD event (~10k rows/day on
|
||||
-- a busy port). Filtering for a single field's history is slow.
|
||||
-- - This table holds ONLY explicit overrides — much smaller, easier
|
||||
-- - This table holds ONLY explicit overrides - much smaller, easier
|
||||
-- to surface on the Interest / Client detail "Field history" panel.
|
||||
-- - The `source` column lets the UI render meaningful provenance
|
||||
-- ("Submitted via supplemental info form on 2026-05-21").
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- 2026-05-21: enforce unique mooring numbers per port at the DB level.
|
||||
--
|
||||
-- The canonical mooring regex is `^[A-Z]+\d+$` (e.g. `A1`, `B12`) and
|
||||
-- the UI gates on uniqueness, but no DB-level constraint existed —
|
||||
-- the UI gates on uniqueness, but no DB-level constraint existed -
|
||||
-- which led to a duplicate E17 row in port-nimara surfaced during UAT.
|
||||
-- Partial unique index lets archived rows reuse a mooring (an old A1
|
||||
-- can be re-issued after the original is archived).
|
||||
|
||||
@@ -41,7 +41,7 @@ export const berths = pgTable(
|
||||
nominalBoatSizeM: numeric('nominal_boat_size_m'),
|
||||
waterDepth: numeric('water_depth'),
|
||||
waterDepthM: numeric('water_depth_m'),
|
||||
/** Entry-unit discriminators — see interests.desiredLengthUnit comment. */
|
||||
/** Entry-unit discriminators - see interests.desiredLengthUnit comment. */
|
||||
lengthUnit: text('length_unit').notNull().default('ft'),
|
||||
widthUnit: text('width_unit').notNull().default('ft'),
|
||||
draftUnit: text('draft_unit').notNull().default('ft'),
|
||||
@@ -95,7 +95,7 @@ export const berths = pgTable(
|
||||
// Pointer to the active per-berth PDF version (Phase 6b). Null until a
|
||||
// rep uploads the first PDF; a later rollback can re-target this column
|
||||
// to any prior `berth_pdf_versions.id`. The full history lives in the
|
||||
// junction table — this column is just the "current" pointer.
|
||||
// junction table - this column is just the "current" pointer.
|
||||
currentPdfVersionId: text('current_pdf_version_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -219,7 +219,7 @@ export const berthMaintenanceLog = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-berth PDF version history (Phase 6b — see plan §3.3 / §4.7b).
|
||||
* Per-berth PDF version history (Phase 6b - see plan §3.3 / §4.7b).
|
||||
*
|
||||
* Each upload creates a new row with a monotonic `versionNumber` per berth.
|
||||
* The active version is referenced by `berths.current_pdf_version_id`. The
|
||||
@@ -247,7 +247,7 @@ export const berthPdfVersions = pgTable(
|
||||
contentSha256: text('content_sha256').notNull(),
|
||||
uploadedBy: text('uploaded_by').notNull(),
|
||||
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */
|
||||
/** Cached signed-URL expiry per §11.1 - re-sign only when within 1h of expiry. */
|
||||
downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }),
|
||||
/** { engine: 'acroform'|'ocr'|'ai', extracted: {...}, conflicts: [...], appliedFields: [...] } */
|
||||
parseResults: jsonb('parse_results'),
|
||||
|
||||
@@ -16,7 +16,7 @@ import { berths } from './berths';
|
||||
import { user } from './users';
|
||||
|
||||
/**
|
||||
* Port-wide brochures (Phase 7 — see plan §3.3 / §4.8).
|
||||
* Port-wide brochures (Phase 7 - see plan §3.3 / §4.8).
|
||||
*
|
||||
* Each port can have multiple brochures (e.g. "General", "Investor Pack")
|
||||
* with one marked as `isDefault`. Archived brochures stay queryable for
|
||||
@@ -73,20 +73,20 @@ export const brochureVersions = pgTable(
|
||||
contentSha256: text('content_sha256').notNull(),
|
||||
uploadedBy: text('uploaded_by').notNull(),
|
||||
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/** Cached signed-URL expiry per §11.1 — re-sign only when within 1h of expiry. */
|
||||
/** Cached signed-URL expiry per §11.1 - re-sign only when within 1h of expiry. */
|
||||
downloadUrlExpiresAt: timestamp('download_url_expires_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [index('idx_brochure_versions_brochure').on(table.brochureId, table.uploadedAt)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Send-out audit log for berth PDFs and brochures (Phase 7 — plan §3.3).
|
||||
* Send-out audit log for berth PDFs and brochures (Phase 7 - plan §3.3).
|
||||
*
|
||||
* One row per recipient per send. `documentKind` discriminates between
|
||||
* `'berth_pdf'` and `'brochure'`; the corresponding `*_version_id` column
|
||||
* pins the exact version sent.
|
||||
*
|
||||
* `berthPdfVersionId` is intentionally a plain text column (no FK) — the
|
||||
* `berthPdfVersionId` is intentionally a plain text column (no FK) - the
|
||||
* referenced table `berth_pdf_versions` is owned by Phase 6b. Loose-coupling
|
||||
* keeps the two phases independent (per Phase 7 task brief).
|
||||
*
|
||||
@@ -106,7 +106,7 @@ export const documentSends = pgTable(
|
||||
/**
|
||||
* Either client_id or interest_id is set (or both). All five FKs use
|
||||
* `onDelete: 'set null'` so the audit row survives if the parent
|
||||
* client/interest/berth/brochure is deleted — `recipient_email`,
|
||||
* client/interest/berth/brochure is deleted - `recipient_email`,
|
||||
* `document_kind`, `body_markdown`, and `from_address` are denormalized
|
||||
* onto the row precisely so the audit trail outlasts the source.
|
||||
*/
|
||||
@@ -116,7 +116,7 @@ export const documentSends = pgTable(
|
||||
/** 'berth_pdf' | 'brochure' */
|
||||
documentKind: text('document_kind').notNull(),
|
||||
berthId: text('berth_id').references(() => berths.id, { onDelete: 'set null' }),
|
||||
/** Forward FK ref — berth_pdf_versions defined in Phase 6b. Loose-coupled. */
|
||||
/** Forward FK ref - berth_pdf_versions defined in Phase 6b. Loose-coupled. */
|
||||
berthPdfVersionId: text('berth_pdf_version_id'),
|
||||
brochureId: text('brochure_id').references(() => brochures.id, { onDelete: 'set null' }),
|
||||
brochureVersionId: text('brochure_version_id').references(() => brochureVersions.id, {
|
||||
@@ -143,14 +143,14 @@ export const documentSends = pgTable(
|
||||
failedAt: timestamp('failed_at', { withTimezone: true }),
|
||||
/** Human-readable failure reason; only meaningful when failedAt is non-null. */
|
||||
errorReason: text('error_reason'),
|
||||
// Phase 6 — async bounce tracking. Populated by the IMAP NDR
|
||||
// Phase 6 - async bounce tracking. Populated by the IMAP NDR
|
||||
// poller (`src/jobs/processors/imap-bounce-poller.ts`) when a
|
||||
// delivery failure message arrives in the configured mailbox and
|
||||
// matches this send via recipient_email + sent_at window.
|
||||
bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo'
|
||||
bounceReason: text('bounce_reason'),
|
||||
bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }),
|
||||
// Phase 4b — email open tracking. When `trackOpens` is true the send
|
||||
// Phase 4b - email open tracking. When `trackOpens` is true the send
|
||||
// includes a 1×1 pixel pointing at /api/public/email-pixel/[sendId].
|
||||
// `firstOpenedAt` + `openCount` are denormalised aggregates so the
|
||||
// sends list can render an "opened" pill without a JOIN.
|
||||
@@ -174,7 +174,7 @@ export const documentSends = pgTable(
|
||||
/**
|
||||
* Per-open log for emails with `trackOpens=true`. The 1×1 pixel
|
||||
* endpoint inserts here on every fetch (Apple Mail privacy proxy will
|
||||
* over-count; most other clients under-count when images are blocked —
|
||||
* over-count; most other clients under-count when images are blocked -
|
||||
* this is the universal email-tracking caveat). Cached aggregates on
|
||||
* `document_sends` keep list rendering fast.
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,7 @@ export const clients = pgTable(
|
||||
/** When this client came out of a "Convert inquiry to client" triage
|
||||
* step, points back at the originating `website_submissions` row.
|
||||
* Drives the conversion-funnel-by-source chart. Migration 0065
|
||||
* installs the FK with ON DELETE SET NULL — Drizzle doesn't reflect
|
||||
* installs the FK with ON DELETE SET NULL - Drizzle doesn't reflect
|
||||
* it here to avoid the cross-file circular import. */
|
||||
sourceInquiryId: text('source_inquiry_id'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
@@ -89,7 +89,7 @@ export const clientContacts = pgTable(
|
||||
label: text('label'), // primary, secondary, work, personal, broker, assistant
|
||||
isPrimary: boolean('is_primary').notNull().default(false),
|
||||
notes: text('notes'),
|
||||
// Phase 3 — origin tracking.
|
||||
// Phase 3 - origin tracking.
|
||||
// source: 'manual' | 'imported' | 'eoi-custom-input'
|
||||
// source_document_id: when source='eoi-custom-input', points at the
|
||||
// EOI document this row was spawned from. Surfaces an [EOI] badge
|
||||
@@ -256,7 +256,7 @@ export const clientAddresses = pgTable(
|
||||
/** ISO-3166-1 alpha-2 country code. */
|
||||
countryIso: text('country_iso'),
|
||||
isPrimary: boolean('is_primary').notNull().default(true),
|
||||
// Phase 3 — origin tracking, same pattern as client_contacts.
|
||||
// Phase 3 - origin tracking, same pattern as client_contacts.
|
||||
source: text('source').notNull().default('manual'),
|
||||
sourceDocumentId: text('source_document_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -39,7 +39,7 @@ export const files = pgTable(
|
||||
* client folder. NULL for client/yacht/company-level uploads.
|
||||
*
|
||||
* Added by migration 0078; not yet wired into ensureEntityFolder
|
||||
* (interest subfolder nesting) — see master UAT line 728+ for the
|
||||
* (interest subfolder nesting) - see master UAT line 728+ for the
|
||||
* remaining work plan.
|
||||
*/
|
||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||||
@@ -64,7 +64,7 @@ export const files = pgTable(
|
||||
index('idx_files_folder').on(table.folderId),
|
||||
index('idx_files_port_folder').on(table.portId, table.folderId),
|
||||
// Composite indexes for the aggregated-projection queries
|
||||
// (`listFilesAggregatedByEntity`) — every join carries a defense-in-
|
||||
// (`listFilesAggregatedByEntity`) - every join carries a defense-in-
|
||||
// depth `port_id` filter so the leading column matters at scale.
|
||||
index('idx_files_port_client').on(table.portId, table.clientId),
|
||||
index('idx_files_port_company').on(table.portId, table.companyId),
|
||||
@@ -84,8 +84,8 @@ export const documents = pgTable(
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||||
// H-01: nullable; tolerate the owning client being hard-deleted (rare —
|
||||
// archive is the normal path — but if it happens the document row
|
||||
// H-01: nullable; tolerate the owning client being hard-deleted (rare -
|
||||
// archive is the normal path - but if it happens the document row
|
||||
// should outlive it so the audit trail stays intact).
|
||||
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
|
||||
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
|
||||
@@ -112,22 +112,22 @@ export const documents = pgTable(
|
||||
signedFileId: text('signed_file_id').references(() => files.id, { onDelete: 'set null' }),
|
||||
isManualUpload: boolean('is_manual_upload').notNull().default(false),
|
||||
/** Email addresses CC'd on the completion notification (the
|
||||
* passive Documenso CC concept — see plan Q4). Per-document set
|
||||
* passive Documenso CC concept - see plan Q4). Per-document set
|
||||
* by the rep; doesn't gate signing. */
|
||||
completionCcEmails: text('completion_cc_emails').array().default([]),
|
||||
/** Optional auto-reminder cadence — when set, a daily worker
|
||||
/** Optional auto-reminder cadence - when set, a daily worker
|
||||
* fires `sendSigningReminder()` for unsigned signers every
|
||||
* N days until they complete. Null = manual reminders only. */
|
||||
autoReminderIntervalDays: integer('auto_reminder_interval_days'),
|
||||
notes: text('notes'),
|
||||
/** Phase 6 polish — rep-authored note inserted above the CTA in
|
||||
/** Phase 6 polish - rep-authored note inserted above the CTA in
|
||||
* every signing-invitation email for THIS document. Falls back to
|
||||
* the empty string when null. Plain-text (XSS-escaped by the
|
||||
* email renderer); not Markdown. */
|
||||
invitationMessage: text('invitation_message'),
|
||||
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
|
||||
reminderCadenceOverride: integer('reminder_cadence_override'),
|
||||
// Phase 3 — per-document field overrides. When NULL, the canonical
|
||||
// Phase 3 - per-document field overrides. When NULL, the canonical
|
||||
// client/yacht record value flows through; when set, this document
|
||||
// uses the override without touching the underlying record. Mirrors
|
||||
// the AcroForm field set per docs/eoi-documenso-field-mapping.md.
|
||||
@@ -166,7 +166,7 @@ export const documents = pgTable(
|
||||
index('idx_docs_documenso_numeric_id').on(table.documensoNumericId),
|
||||
index('idx_docs_folder').on(table.folderId),
|
||||
// Composite indexes for the aggregated-projection queries
|
||||
// (`listInflightWorkflowsAggregatedByEntity`) — every join carries a
|
||||
// (`listInflightWorkflowsAggregatedByEntity`) - every join carries a
|
||||
// defense-in-depth `port_id` filter so the leading column matters at scale.
|
||||
index('idx_docs_port_client').on(table.portId, table.clientId),
|
||||
index('idx_docs_port_company').on(table.portId, table.companyId),
|
||||
@@ -191,12 +191,12 @@ export const documentSigners = pgTable(
|
||||
signedAt: timestamp('signed_at', { withTimezone: true }),
|
||||
signingUrl: text('signing_url'),
|
||||
embeddedUrl: text('embedded_url'),
|
||||
/** Phase 1+2 lifecycle tracking — set by the send-invitation endpoint
|
||||
/** Phase 1+2 lifecycle tracking - set by the send-invitation endpoint
|
||||
* and the Documenso webhook handler respectively. */
|
||||
invitedAt: timestamp('invited_at', { withTimezone: true }),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }),
|
||||
lastReminderSentAt: timestamp('last_reminder_sent_at', { withTimezone: true }),
|
||||
/** Documenso recipient token — used for token-based lookup when the
|
||||
/** Documenso recipient token - used for token-based lookup when the
|
||||
* webhook fires (more robust than email match when one address
|
||||
* serves multiple roles). */
|
||||
signingToken: text('signing_token'),
|
||||
@@ -350,7 +350,7 @@ export const formSubmissions = pgTable(
|
||||
|
||||
/**
|
||||
* Per-port folder tree for organising documents. Self-referencing
|
||||
* via parent_id; null parent = root. Unlimited depth — the UI is the
|
||||
* via parent_id; null parent = root. Unlimited depth - the UI is the
|
||||
* gate (collapsed sidebar tree + breadcrumb header). Cycle prevention
|
||||
* happens in the service layer (parent_id chain walk on insert/move).
|
||||
*
|
||||
@@ -367,7 +367,7 @@ export const documentFolders = pgTable(
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
// Null = root. ON DELETE NO ACTION on the FK (added by migration
|
||||
// 0050) — the service bubbles children up to the deleted folder's
|
||||
// 0050) - the service bubbles children up to the deleted folder's
|
||||
// parent in a transaction instead of cascading.
|
||||
parentId: text('parent_id'),
|
||||
name: text('name').notNull(),
|
||||
|
||||
@@ -40,7 +40,7 @@ export const expenses = pgTable(
|
||||
/**
|
||||
* True when the rep deliberately created the expense WITHOUT a receipt
|
||||
* (e.g. the receipt was lost or never issued). Surfaces a warning at
|
||||
* creation time AND in the PDF export — the legacy parent-company flow
|
||||
* creation time AND in the PDF export - the legacy parent-company flow
|
||||
* may refuse to reimburse expenses without proof, so the warning is
|
||||
* load-bearing for ops.
|
||||
*/
|
||||
@@ -52,7 +52,7 @@ export const expenses = pgTable(
|
||||
/**
|
||||
* Free-text trip / event label so reps can group expenses for one
|
||||
* yacht show or business trip (e.g. "Palm Beach 2026"). Deliberately
|
||||
* un-normalized — events are 6–12/year and full event-management
|
||||
* un-normalized - events are 6–12/year and full event-management
|
||||
* functionality lives outside this CRM. The autocomplete on the
|
||||
* expense form keeps spellings consistent so group-by works.
|
||||
*/
|
||||
@@ -117,7 +117,7 @@ export const invoices = pgTable(
|
||||
paymentDate: date('payment_date'),
|
||||
paymentMethod: text('payment_method'),
|
||||
paymentReference: text('payment_reference'),
|
||||
// H-01: nullable — losing the rendered invoice PDF shouldn't bring
|
||||
// H-01: nullable - losing the rendered invoice PDF shouldn't bring
|
||||
// down the invoice row (totals + payments are the source of truth).
|
||||
pdfFileId: text('pdf_file_id').references(() => files.id, { onDelete: 'set null' }),
|
||||
/** Optional link to a sales interest. When the invoice is paid and `kind`
|
||||
|
||||
@@ -71,7 +71,7 @@ export * from './website-submissions';
|
||||
// Pre-EOI supplemental form tokens
|
||||
export * from './supplemental-forms';
|
||||
|
||||
// Pipeline refactor — qualification criteria, payment records
|
||||
// Pipeline refactor - qualification criteria, payment records
|
||||
export * from './pipeline';
|
||||
|
||||
// Saved PDF-report templates (`/api/v1/reports/templates`).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Every time a field on an interest or its linked client is overridden
|
||||
* via an explicit channel (today: supplemental-info form submission;
|
||||
* future: form-templates, AI-assisted extraction acceptance), a row
|
||||
* lands here. Distinct from `audit_logs` — that table tracks every
|
||||
* lands here. Distinct from `audit_logs` - that table tracks every
|
||||
* CRUD event for compliance; this one tracks only deliberate overrides
|
||||
* so the Interest + Client "Field history" panels can surface them
|
||||
* compactly.
|
||||
@@ -28,7 +28,7 @@ export const interestFieldHistory = pgTable(
|
||||
.references(() => ports.id),
|
||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'cascade' }),
|
||||
/** Denormalized for fast lookup on the Client detail "Field history"
|
||||
* panel — overrides that come in via a supplemental-info form
|
||||
* panel - overrides that come in via a supplemental-info form
|
||||
* carry both interest + client refs. Direct-edit overrides may
|
||||
* only carry one. */
|
||||
clientId: text('client_id').references(() => clients.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -30,7 +30,7 @@ export const interests = pgTable(
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'restrict' }),
|
||||
// H-01: client is required and design intent is archive-first — the
|
||||
// H-01: client is required and design intent is archive-first - the
|
||||
// service-layer hard-delete path nullifies FKs explicitly. RESTRICT
|
||||
// is a defensive backstop against an ad-hoc DB hard-delete that
|
||||
// would otherwise leave the interest pointing at a missing client.
|
||||
@@ -90,7 +90,7 @@ export const interests = pgTable(
|
||||
/** Recommender inputs - dual-stored. ft is the canonical unit the
|
||||
* recommender SQL queries on; m is the human-friendly entry the rep
|
||||
* may have actually typed. The matching `*_unit` column says which
|
||||
* side is source-of-truth — display prefers that side and recomputes
|
||||
* side is source-of-truth - display prefers that side and recomputes
|
||||
* the other so the rep's literal entry doesn't drift through repeated
|
||||
* conversions. Resolver treats nulls as "no constraint" on that axis. */
|
||||
desiredLengthFt: numeric('desired_length_ft'),
|
||||
@@ -188,7 +188,7 @@ export const interestNotes = pgTable(
|
||||
/** Snapshot of the linked interest's pipeline_stage at note creation.
|
||||
* Lets a rep see how the deal's notes evolved across the lifecycle
|
||||
* (e.g. concerns raised at qualified vs after reservation). Backfill
|
||||
* not attempted for pre-2026-05-15 rows — they stay null. */
|
||||
* not attempted for pre-2026-05-15 rows - they stay null. */
|
||||
pipelineStageAtCreation: text('pipeline_stage_at_creation'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -23,7 +23,7 @@ export const reminders = pgTable(
|
||||
status: text('status').notNull().default('pending'), // pending, snoozed, completed, dismissed
|
||||
assignedTo: text('assigned_to'), // user ID
|
||||
createdBy: text('created_by').notNull(),
|
||||
// H-01: nullable — reminder rows stay around as historical follow-up
|
||||
// H-01: nullable - reminder rows stay around as historical follow-up
|
||||
// records even if the linked client/interest/berth is hard-deleted.
|
||||
clientId: text('client_id').references(() => clients.id, { onDelete: 'set null' }),
|
||||
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||||
@@ -216,7 +216,7 @@ export type NewGeneratedReport = typeof generatedReports.$inferInsert;
|
||||
// Per-interaction record of communication with a client about a specific
|
||||
// interest. Sales reps log every email / call / WhatsApp / meeting touch
|
||||
// here so the team has a structured history of "what was the last
|
||||
// conversation about" — beyond the single `dateLastContact` timestamp on
|
||||
// conversation about" - beyond the single `dateLastContact` timestamp on
|
||||
// the interest itself.
|
||||
//
|
||||
// Notes are for free-form thinking / context. This table is for
|
||||
@@ -234,13 +234,13 @@ export const interestContactLog = pgTable(
|
||||
.notNull()
|
||||
.references(() => interests.id, { onDelete: 'cascade' }),
|
||||
/** When the actual conversation happened (not when the log entry
|
||||
* was recorded — those can differ if a rep logs after the fact). */
|
||||
* was recorded - those can differ if a rep logs after the fact). */
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
/** email | phone | whatsapp | in_person | video | other */
|
||||
channel: text('channel').notNull(),
|
||||
/** outbound | inbound — who initiated the contact. */
|
||||
/** outbound | inbound - who initiated the contact. */
|
||||
direction: text('direction').notNull().default('outbound'),
|
||||
/** Short free text — "Discussed yacht size, asked about tax structure". */
|
||||
/** Short free text - "Discussed yacht size, asked about tax structure". */
|
||||
summary: text('summary').notNull(),
|
||||
/** Raw Web Speech API transcript captured at log time, kept separate
|
||||
* from the rep-polished `summary` so the original utterance survives
|
||||
@@ -253,7 +253,7 @@ export const interestContactLog = pgTable(
|
||||
* the interest for follow-up. Stored as the original choice so the
|
||||
* UI can re-render it; the actual reminder lives in `reminders`. */
|
||||
followUpAt: timestamp('follow_up_at', { withTimezone: true }),
|
||||
/** ID of the auto-created reminder, if any — lets us update/cancel
|
||||
/** ID of the auto-created reminder, if any - lets us update/cancel
|
||||
* the reminder when the log entry is edited. */
|
||||
reminderId: text('reminder_id').references(() => reminders.id, { onDelete: 'set null' }),
|
||||
createdBy: text('created_by').notNull(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Pipeline-refactor tables — per-port qualification criteria, per-interest
|
||||
* Pipeline-refactor tables - per-port qualification criteria, per-interest
|
||||
* qualification state, and payment records (no invoice generation).
|
||||
*
|
||||
* See migrations/0062_pipeline_refactor.sql.
|
||||
@@ -73,7 +73,7 @@ export const interestQualifications = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* Payment records. The CRM does NOT generate invoices — clients pay banks
|
||||
* Payment records. The CRM does NOT generate invoices - clients pay banks
|
||||
* directly. We record that money was received (or refunded) with an
|
||||
* optional uploaded receipt for audit purposes.
|
||||
*
|
||||
@@ -95,7 +95,7 @@ export const payments = pgTable(
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
/** 'deposit' | 'balance' | 'refund' | 'other' — `refund` rows carry
|
||||
/** 'deposit' | 'balance' | 'refund' | 'other' - `refund` rows carry
|
||||
* negative amounts so the running total nets out correctly. */
|
||||
paymentType: text('payment_type').notNull(),
|
||||
amount: numeric('amount').notNull(),
|
||||
|
||||
@@ -21,7 +21,7 @@ export const reportTemplates = pgTable(
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
/** Mirrors the discriminator on ReportConfig — 'dashboard' |
|
||||
/** Mirrors the discriminator on ReportConfig - 'dashboard' |
|
||||
* 'clients' | 'berths' | 'interests'. Validated at the route
|
||||
* layer. */
|
||||
kind: text('kind').notNull(),
|
||||
|
||||
@@ -55,7 +55,7 @@ export const berthReservations = pgTable(
|
||||
// Cover the FKs Postgres doesn't auto-index. Without these, deleting
|
||||
// (or restrict-checking) the parent interest / contract file row
|
||||
// requires a full scan of berth_reservations. (idx_br_interest is
|
||||
// already used by berth_recommendations — namespace this one.)
|
||||
// already used by berth_recommendations - namespace this one.)
|
||||
index('idx_brr_interest').on(table.interestId),
|
||||
index('idx_brr_contract_file').on(table.contractFileId),
|
||||
uniqueIndex('idx_br_active')
|
||||
|
||||
@@ -47,7 +47,7 @@ export const residentialClients = pgTable(
|
||||
/**
|
||||
* Optional link to a matching record in the main `clients` table.
|
||||
* Populated by `findAndLinkMatchingMainClient` after create, or
|
||||
* manually via the admin UI. ON DELETE SET NULL — the residential
|
||||
* manually via the admin UI. ON DELETE SET NULL - the residential
|
||||
* record outlives a GDPR wipe of the main client. Migration 0080
|
||||
* adds the FK + supporting index.
|
||||
*/
|
||||
@@ -119,7 +119,7 @@ export const residentialInterests = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* Threaded notes for residential clients — mirror the marina-side
|
||||
* Threaded notes for residential clients - mirror the marina-side
|
||||
* `clientNotes` shape so the polymorphic NotesList component works
|
||||
* with `entityType='residential_clients'`.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* generates one of these rows + emails the client a public link
|
||||
* containing the token. The client fills out a form prefilled with
|
||||
* whatever's already on file (name, address, contacts, yacht info)
|
||||
* and submits — the submission updates the client + interest rows.
|
||||
* and submits - the submission updates the client + interest rows.
|
||||
*
|
||||
* One-shot: `consumedAt` flips on submit, the token can't be reused.
|
||||
* Tokens expire after 30 days even if unused.
|
||||
|
||||
@@ -42,10 +42,10 @@ export const auditLogs = pgTable(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
revertOf: text('revert_of').references((): any => auditLogs.id),
|
||||
metadata: jsonb('metadata').default({}),
|
||||
/** 'info' | 'warning' | 'error' | 'critical' — drives the row badge
|
||||
/** 'info' | 'warning' | 'error' | 'critical' - drives the row badge
|
||||
* in the inspector. Most user actions are 'info'. */
|
||||
severity: text('severity').notNull().default('info'),
|
||||
/** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' — lets the
|
||||
/** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' - lets the
|
||||
* UI filter by event origin without grepping action names. */
|
||||
source: text('source').notNull().default('user'),
|
||||
/** Full-text search column. **Read-only / DB-managed**: the column is
|
||||
@@ -53,7 +53,7 @@ export const auditLogs = pgTable(
|
||||
* 0014_black_banshee.sql (covers action + entity_type + entity_id +
|
||||
* user_id). Drizzle has no first-class marker for generated columns,
|
||||
* so writes through this schema property would be rejected by
|
||||
* Postgres at SQL level — never set this from application code.
|
||||
* Postgres at SQL level - never set this from application code.
|
||||
* M-SC04: documented to prevent accidental write attempts. */
|
||||
searchText: tsvector('search_text'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -140,7 +140,7 @@ export const systemSettings = pgTable(
|
||||
},
|
||||
(table) => [
|
||||
// Migration 0047 rebuilds this index with `NULLS NOT DISTINCT` so a
|
||||
// global setting (port_id IS NULL) is unique by key alone — the
|
||||
// global setting (port_id IS NULL) is unique by key alone - the
|
||||
// default `NULLS DISTINCT` semantics let duplicates accumulate.
|
||||
// Drizzle's `uniqueIndex` builder doesn't surface NULLS NOT DISTINCT,
|
||||
// so the migration is the source of truth for that flag and
|
||||
@@ -287,7 +287,7 @@ export const errorEvents = pgTable(
|
||||
/**
|
||||
* Equal to the request id minted in `withAuth` and surfaced to the
|
||||
* client via `X-Request-Id`. Acting as the PK lets us write
|
||||
* idempotently when duplicate webhook events fire — `ON CONFLICT
|
||||
* idempotently when duplicate webhook events fire - `ON CONFLICT
|
||||
* DO NOTHING` skips re-inserting the same error.
|
||||
*/
|
||||
requestId: text('request_id').primaryKey(),
|
||||
@@ -297,13 +297,13 @@ export const errorEvents = pgTable(
|
||||
userId: text('user_id'),
|
||||
statusCode: integer('status_code').notNull(),
|
||||
method: text('method').notNull(),
|
||||
/** Pathname only (no query string) — keeps PII and tokens out. */
|
||||
/** Pathname only (no query string) - keeps PII and tokens out. */
|
||||
path: text('path').notNull(),
|
||||
errorName: text('error_name'),
|
||||
errorMessage: text('error_message'),
|
||||
/** First 4 KB of the stack — full stacks live in pino, this is for inspector readability. */
|
||||
/** First 4 KB of the stack - full stacks live in pino, this is for inspector readability. */
|
||||
errorStack: text('error_stack'),
|
||||
/** Sanitized request body (max 1 KB) — secret-sounding keys redacted. */
|
||||
/** Sanitized request body (max 1 KB) - secret-sounding keys redacted. */
|
||||
requestBodyExcerpt: text('request_body_excerpt'),
|
||||
userAgent: text('user_agent'),
|
||||
ipAddress: text('ip_address'),
|
||||
|
||||
@@ -5,14 +5,14 @@ import { user } from './users';
|
||||
import { documentSends } from './brochures';
|
||||
|
||||
/**
|
||||
* Phase 4c — tracked redirect links. A short URL `/q/<slug>` records a
|
||||
* Phase 4c - tracked redirect links. A short URL `/q/<slug>` records a
|
||||
* click and 302s the recipient on to `targetUrl`. The matching click
|
||||
* row is fire-and-forget so the redirect stays snappy; an aggregate
|
||||
* `clickCount` on the parent row keeps "was clicked at all" queries
|
||||
* cheap.
|
||||
*
|
||||
* `sendId` is the optional link back to the originating outbound email
|
||||
* — set when the link is minted via the email-composer flow so reps can
|
||||
* - set when the link is minted via the email-composer flow so reps can
|
||||
* see per-email click-throughs. Manual one-off short links leave it null.
|
||||
*/
|
||||
export const trackedLinks = pgTable(
|
||||
@@ -41,7 +41,7 @@ export const trackedLinks = pgTable(
|
||||
);
|
||||
|
||||
/** Per-click log. Apple Mail privacy proxy will pre-fetch tracked link
|
||||
* URLs the same way it does pixels — clicks from iOS users are
|
||||
* URLs the same way it does pixels - clicks from iOS users are
|
||||
* over-counted. Standard email-tracking caveats apply. */
|
||||
export const trackedLinkClicks = pgTable(
|
||||
'tracked_link_clicks',
|
||||
|
||||
@@ -72,7 +72,7 @@ export type RolePermissions = {
|
||||
* an interest). Carved out from `invoices.record_payment` so a port
|
||||
* that does not use the invoicing module at all can still grant
|
||||
* payment-recording rights to sales reps. `view` follows interests.view
|
||||
* at the route level — this gate only governs the UI affordance.
|
||||
* at the route level - this gate only governs the UI affordance.
|
||||
*/
|
||||
payments: {
|
||||
view: boolean;
|
||||
@@ -166,7 +166,7 @@ export type RolePermissions = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-table column visibility — drives the `<ColumnPicker>` and the
|
||||
* Per-table column visibility - drives the `<ColumnPicker>` and the
|
||||
* DataTable `columnVisibility` state. `hiddenColumns` is the source of
|
||||
* truth; an entry's absence means "show this column" (so newly-added
|
||||
* columns show by default for existing users without us having to
|
||||
@@ -198,20 +198,27 @@ export type UserPreferences = {
|
||||
/**
|
||||
* Dashboard widget visibility, keyed by widget id from the registry
|
||||
* in `src/components/dashboard/widget-registry.ts`. Missing keys fall
|
||||
* back to `defaultVisible` from the registry — so adding a new widget
|
||||
* back to `defaultVisible` from the registry - so adding a new widget
|
||||
* surfaces it for everyone without a migration. `false` hides it.
|
||||
*/
|
||||
dashboardWidgets?: Record<string, boolean>;
|
||||
/**
|
||||
* Ordered list of widget ids — drives the dashboard render order so a
|
||||
* rep can drag tiles around and have the layout persist. Missing
|
||||
* widgets (ids not in the array) render after the listed ones in
|
||||
* registry order, so adding a new widget always surfaces it without
|
||||
* a migration. Order is scoped per widget group implicitly — the
|
||||
* shell groups by `widget.group` first (chart / rail / feed) then
|
||||
* sorts within the group by this array.
|
||||
* Ordered list of widget ids for the **desktop / xl layout** (charts
|
||||
* column + rails aside + feed row, side-by-side). Drives the render
|
||||
* order at viewport widths >= 1280px. Missing widgets fall through to
|
||||
* registry order so newly-added widgets always surface.
|
||||
*/
|
||||
dashboardWidgetOrder?: string[];
|
||||
/**
|
||||
* Ordered list of widget ids for the **stacked layout** (single
|
||||
* column at < xl). Reps reasonably want a different order on mobile
|
||||
* vs desktop - Reminders + Activity top on the phone, Pipeline Funnel
|
||||
* top on a 27" monitor. When unset, the dashboard falls back to
|
||||
* `dashboardWidgetOrder` (then registry order) so a rep who only
|
||||
* customized desktop sees the same order on a phone until they
|
||||
* customize there too.
|
||||
*/
|
||||
dashboardWidgetOrderMobile?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -274,7 +281,7 @@ export const userProfiles = pgTable(
|
||||
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
||||
/**
|
||||
* Canonical first/last name pair. Added 2026-05-09 as the primary
|
||||
* source for greetings, invoicing, and DocSign field-merging — the
|
||||
* source for greetings, invoicing, and DocSign field-merging - the
|
||||
* older `displayName` is now kept around as a derived/optional
|
||||
* override (e.g. for nicknames or vanity formatting). When migrating
|
||||
* production, backfill these columns from displayName by splitting
|
||||
@@ -293,7 +300,7 @@ export const userProfiles = pgTable(
|
||||
*/
|
||||
username: text('username'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
/** FK into the polymorphic `files` table — the avatar is stored
|
||||
/** FK into the polymorphic `files` table - the avatar is stored
|
||||
* via getStorageBackend() so an S3↔filesystem swap carries it
|
||||
* without breaking the URL. The legacy `avatarUrl` column is
|
||||
* kept for any external photo sources but the file pointer wins
|
||||
@@ -330,7 +337,7 @@ export const roles = pgTable('roles', {
|
||||
* Per-user permission overrides layered on top of the role's baseline for
|
||||
* a specific port. Each row carries a `Partial<RolePermissions>` map; any
|
||||
* explicitly-set leaf wins over the role + port-role-override chain. Most
|
||||
* users will never have a row here — it exists for the rare "give Alice
|
||||
* users will never have a row here - it exists for the rare "give Alice
|
||||
* the same role as her team but let her run permanent deletes" case.
|
||||
*
|
||||
* Effective permission resolution lives in `getEffectivePermissions` in
|
||||
@@ -344,7 +351,7 @@ export const userPermissionOverrides = pgTable(
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
// onDelete: 'cascade' is intentional here (not 'set null' as a stale 2026-05-12
|
||||
// audit item suggested). A permission override has no semantic value without
|
||||
// the user it grants permissions to — preserving a row with user_id=NULL
|
||||
// the user it grants permissions to - preserving a row with user_id=NULL
|
||||
// would be an orphan with no audit value, since the override is per-user
|
||||
// additive permissions, not a historical event we need to retain.
|
||||
userId: text('user_id')
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ports } from './ports';
|
||||
* Raw capture of every website inquiry submission, dual-written from the
|
||||
* marketing site alongside its existing NocoDB write. Acts as a passive
|
||||
* collector while the website still uses NocoDB as its primary system of
|
||||
* record — the new CRM observes incoming traffic without altering it,
|
||||
* record - the new CRM observes incoming traffic without altering it,
|
||||
* letting us validate the data flow before any cutover.
|
||||
*
|
||||
* v1 deliberately stores the raw payload as JSON without promoting to
|
||||
|
||||
@@ -46,7 +46,7 @@ export const yachts = pgTable(
|
||||
status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away'
|
||||
notes: text('notes'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
// Phase 3 — origin tracking. eoi-generated marks yachts that were
|
||||
// Phase 3 - origin tracking. eoi-generated marks yachts that were
|
||||
// spawned via the EOI dialog's "+ New yacht" inline form, so the
|
||||
// detail page can surface an [EOI] badge + link to the originating
|
||||
// document.
|
||||
|
||||
@@ -64,7 +64,7 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
// Branding intentionally left empty — admin uploads their own assets
|
||||
// Branding intentionally left empty - admin uploads their own assets
|
||||
// via /admin/branding rather than inheriting Port Nimara's look.
|
||||
},
|
||||
];
|
||||
@@ -123,7 +123,7 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
brandingPairs.push(['branding_primary_color', def.primaryColor]);
|
||||
|
||||
for (const [key, value] of brandingPairs) {
|
||||
// Skip when an existing row is already present — preserves admin
|
||||
// Skip when an existing row is already present - preserves admin
|
||||
// edits across re-seeds. Pair (key, portId) is uniquely indexed.
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
@@ -187,7 +187,7 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'residential_partner',
|
||||
description:
|
||||
'External partner who handles residential inquiries. Sees only the residential pages — no marina clients, yachts, berths, or financial data.',
|
||||
'External partner who handles residential inquiries. Sees only the residential pages - no marina clients, yachts, berths, or financial data.',
|
||||
permissions: RESIDENTIAL_PARTNER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
|
||||
@@ -412,7 +412,7 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
},
|
||||
};
|
||||
|
||||
// Residential Partner — for an outside party who handles residential
|
||||
// Residential Partner - for an outside party who handles residential
|
||||
// inquiries on the marina's behalf. Sees only the residential pages and
|
||||
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
||||
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
|
||||
@@ -112,7 +112,7 @@ interface SyntheticClientSpec {
|
||||
/** Archive the CLIENT after creation. When 'rich', fabricate
|
||||
* archive_metadata so the smart-restore wizard surfaces reversals. */
|
||||
archive?: 'simple' | 'rich';
|
||||
/** Acquisition source — varied across the fixture set so the list view
|
||||
/** Acquisition source - varied across the fixture set so the list view
|
||||
* looks like a real funnel rather than a wall of "Manual". */
|
||||
source?: 'website' | 'manual' | 'referral' | 'broker';
|
||||
/** How long ago (in days) this client record was created. Spreads the
|
||||
@@ -131,7 +131,7 @@ interface SyntheticClientSpec {
|
||||
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold.
|
||||
*/
|
||||
/**
|
||||
* Believable demo dataset — names, emails, phone numbers, addresses, and
|
||||
* Believable demo dataset - names, emails, phone numbers, addresses, and
|
||||
* acquisition sources read like a real marina's prospect list rather
|
||||
* than fixtures keyed on enum names. The `tag` field still carries the
|
||||
* stage/role identity for selectors and intra-seed wiring; nothing in
|
||||
@@ -700,7 +700,7 @@ export async function seedSyntheticPortData(
|
||||
// ── 9. Reservations ─────────────────────────────────────────────────────
|
||||
// One active reservation on the under_offer berth held by Carla,
|
||||
// one cancelled on an available berth.
|
||||
// berthReservations requires a yacht — wire both to the charter co.
|
||||
// berthReservations requires a yacht - wire both to the charter co.
|
||||
// flagship since Carla / Olivia don't own yachts yet.
|
||||
const sharedYachtId = charterYachtRow[1]!.id;
|
||||
await tx.insert(berthReservations).values([
|
||||
@@ -793,7 +793,7 @@ export async function seedSyntheticPortData(
|
||||
email: 'rina.resident@test.local',
|
||||
phone: '+1 555 020 0002',
|
||||
source: 'referral' as const,
|
||||
notes: 'Synthetic residential lead — qualified.',
|
||||
notes: 'Synthetic residential lead - qualified.',
|
||||
},
|
||||
])
|
||||
.returning({ id: residentialClients.id });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Synthetic seed (the "every pipeline stage" fixture).
|
||||
*
|
||||
* Bootstraps the same ports/roles/profile as `seed.ts` then loads
|
||||
* `seedSyntheticPortData()` per port — 12 clients, one per pipeline
|
||||
* `seedSyntheticPortData()` per port - 12 clients, one per pipeline
|
||||
* stage plus archive variants, designed for thoroughly testing the
|
||||
* CRM end-to-end.
|
||||
*
|
||||
|
||||
@@ -44,7 +44,7 @@ const FAKER_SEED = 20260512;
|
||||
const WIDE_MARKER = 'wide-synthetic';
|
||||
|
||||
// Acquisition source distribution roughly matching how a real marina
|
||||
// funnel breaks down — most opportunity comes through the website, then
|
||||
// funnel breaks down - most opportunity comes through the website, then
|
||||
// referrals, then brokers, then manual entry. Tweak when product data
|
||||
// gives us better numbers.
|
||||
const SOURCE_DISTRIBUTION: Array<{
|
||||
@@ -96,7 +96,7 @@ export async function seedWideSyntheticPortData(
|
||||
.where(eq(berths.portId, portId));
|
||||
|
||||
if (portBerths.length === 0) {
|
||||
console.warn(` [${portSlug}] no berths in port — wide seed skipping`);
|
||||
console.warn(` [${portSlug}] no berths in port - wide seed skipping`);
|
||||
return { clients: 0, interests: 0 };
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ export async function seedWideSyntheticPortData(
|
||||
|
||||
if (interest) {
|
||||
interestsInserted++;
|
||||
// ~50% of interests link to a berth — late-stage flow needs
|
||||
// ~50% of interests link to a berth - late-stage flow needs
|
||||
// one, early-stage doesn't have to.
|
||||
if (faker.number.float({ min: 0, max: 1 }) < 0.5) {
|
||||
await tx.insert(interestBerths).values({
|
||||
|
||||
@@ -21,7 +21,7 @@ async function seed() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Seeding Port Nimara CRM (wide synthetic — ${target} clients/port)...`);
|
||||
console.log(`Seeding Port Nimara CRM (wide synthetic - ${target} clients/port)...`);
|
||||
|
||||
const portIds = await seedBootstrap();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
|
||||
import { db } from './index';
|
||||
|
||||
/**
|
||||
* Drizzle transaction client type — the argument shape `db.transaction`'s
|
||||
* Drizzle transaction client type - the argument shape `db.transaction`'s
|
||||
* callback receives. Exported so service helpers that take a `tx`
|
||||
* parameter can spell the type instead of falling back to `any`.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user