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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user