Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import {
|
|
pgTable,
|
|
text,
|
|
boolean,
|
|
integer,
|
|
timestamp,
|
|
jsonb,
|
|
index,
|
|
uniqueIndex,
|
|
primaryKey,
|
|
} from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
import { ports } from './ports';
|
|
|
|
export const clients = pgTable(
|
|
'clients',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
fullName: text('full_name').notNull(),
|
|
/** ISO-3166-1 alpha-2 nationality code. */
|
|
nationalityIso: text('nationality_iso'),
|
|
preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp
|
|
preferredLanguage: text('preferred_language'),
|
|
/** IANA timezone, e.g. 'Europe/Warsaw'. Validated client + server. */
|
|
timezone: text('timezone'),
|
|
source: text('source'), // website, manual, referral, broker
|
|
sourceDetails: text('source_details'),
|
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
/** Better-auth user id of the operator who archived this client. */
|
|
archivedBy: text('archived_by'),
|
|
/** Free-text reason captured at archive time. Required when archiving a
|
|
* client at deposit_10pct or later (compliance trail). Optional when
|
|
* archiving an early-stage lead. */
|
|
archiveReason: text('archive_reason'),
|
|
/** Per-decision metadata captured during smart-archive flow. Used by
|
|
* the restore wizard to attempt reversal. Shape:
|
|
* { decisions: Array<{ kind, refId, ...specifics }>, decidedAt, decidedBy }
|
|
* See src/lib/services/client-archive.service.ts for the canonical
|
|
* payload schema. */
|
|
archiveMetadata: jsonb('archive_metadata'),
|
|
/** When this client was merged into another (the "loser" of a dedup
|
|
* merge), this points at the surviving client. Used by the
|
|
* /admin/duplicates review queue to redirect any stragglers, and by
|
|
* the unmerge flow to restore. Null for live clients. The Postgres
|
|
* self-FK is installed via migration 0042; Drizzle's table builder
|
|
* doesn't accept self-references in the column factory so the
|
|
* constraint isn't reflected here in `.references(...)`. */
|
|
mergedIntoClientId: text('merged_into_client_id'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_clients_port').on(table.portId),
|
|
index('idx_clients_name').on(table.portId, table.fullName),
|
|
index('idx_clients_archived')
|
|
.on(table.portId)
|
|
.where(sql`${table.archivedAt} IS NULL`),
|
|
index('idx_clients_nationality_iso').on(table.nationalityIso),
|
|
index('idx_clients_merged_into').on(table.mergedIntoClientId),
|
|
],
|
|
);
|
|
|
|
export const clientContacts = pgTable(
|
|
'client_contacts',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
channel: text('channel').notNull(), // email, phone, whatsapp, other
|
|
value: text('value').notNull(),
|
|
/** E.164-normalized phone number (only set when channel='phone'/'whatsapp'). */
|
|
valueE164: text('value_e164'),
|
|
/** ISO-3166-1 alpha-2 of the country this number was parsed against. */
|
|
valueCountry: text('value_country'),
|
|
label: text('label'), // primary, secondary, work, personal, broker, assistant
|
|
isPrimary: boolean('is_primary').notNull().default(false),
|
|
notes: text('notes'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_cc_client').on(table.clientId),
|
|
index('idx_cc_email')
|
|
.on(table.channel, table.value)
|
|
.where(sql`${table.channel} = 'email'`),
|
|
index('idx_cc_phone')
|
|
.on(table.channel, table.value)
|
|
.where(sql`${table.channel} = 'phone'`),
|
|
// At most one is_primary=true per (client_id, channel). Prevents
|
|
// ambiguity when the /clients list pulls "the" primary phone/email.
|
|
uniqueIndex('idx_cc_one_primary_per_channel')
|
|
.on(table.clientId, table.channel)
|
|
.where(sql`${table.isPrimary} = true`),
|
|
],
|
|
);
|
|
|
|
export const clientRelationships = pgTable(
|
|
'client_relationships',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
clientAId: text('client_a_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
clientBId: text('client_b_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
relationshipType: text('relationship_type').notNull(), // referred_by, broker_for, family_member, same_vessel, custom
|
|
description: text('description'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_cr_port').on(table.portId)],
|
|
);
|
|
|
|
export const clientNotes = pgTable(
|
|
'client_notes',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
authorId: text('author_id').notNull(), // user ID
|
|
content: text('content').notNull(),
|
|
mentions: text('mentions').array(), // array of mentioned user IDs
|
|
isLocked: boolean('is_locked').notNull().default(false),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_cn_client').on(table.clientId)],
|
|
);
|
|
|
|
export const clientTags = pgTable(
|
|
'client_tags',
|
|
{
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
tagId: text('tag_id').notNull(), // references tags.id - defined later in system.ts
|
|
},
|
|
(table) => [primaryKey({ columns: [table.clientId, table.tagId] })],
|
|
);
|
|
|
|
export const clientMergeLog = pgTable(
|
|
'client_merge_log',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
survivingClientId: text('surviving_client_id')
|
|
.notNull()
|
|
.references(() => clients.id),
|
|
mergedClientId: text('merged_client_id').notNull(), // the client that was merged away (may no longer exist)
|
|
mergedBy: text('merged_by').notNull(), // user ID
|
|
mergeDetails: jsonb('merge_details').notNull(), // which fields were kept from which record
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_cml_port').on(table.portId)],
|
|
);
|
|
|
|
/**
|
|
* Pairs of clients flagged by the background scoring job as potential
|
|
* duplicates. The `/admin/duplicates` review queue reads from here.
|
|
*
|
|
* Lifecycle:
|
|
* - Background job inserts a row when a pair scores >= the
|
|
* `dedup_review_queue_threshold` system setting.
|
|
* - User reviews in the admin UI and either merges (status='merged')
|
|
* or dismisses (status='dismissed').
|
|
* - Subsequent runs of the scoring job skip pairs already
|
|
* `dismissed` so the same false-positive doesn't keep reappearing.
|
|
* A future score increase recreates the row.
|
|
*
|
|
* Pairs are stored canonically with `clientAId < clientBId` (string
|
|
* comparison) so the same pair only generates one row regardless of
|
|
* scoring direction.
|
|
*/
|
|
export const clientMergeCandidates = pgTable(
|
|
'client_merge_candidates',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
clientAId: text('client_a_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
clientBId: text('client_b_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
score: integer('score').notNull(),
|
|
/** Human-readable rule list, e.g. ["email match", "phone match"]. */
|
|
reasons: jsonb('reasons').notNull(),
|
|
status: text('status').notNull().default('pending'), // pending | dismissed | merged
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
|
resolvedBy: text('resolved_by'),
|
|
},
|
|
(table) => [
|
|
index('idx_cmc_port_status').on(table.portId, table.status),
|
|
// Same pair shouldn't surface twice - enforce uniqueness on the
|
|
// canonical (a < b) ordering.
|
|
uniqueIndex('idx_cmc_pair').on(table.portId, table.clientAId, table.clientBId),
|
|
],
|
|
);
|
|
|
|
export const clientAddresses = pgTable(
|
|
'client_addresses',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
|
label: text('label').notNull().default('Primary'),
|
|
streetAddress: text('street_address'),
|
|
city: text('city'),
|
|
/** ISO 3166-2 subdivision code (e.g. 'PL-MZ', 'US-CA'). Optional. */
|
|
subdivisionIso: text('subdivision_iso'),
|
|
postalCode: text('postal_code'),
|
|
/** ISO-3166-1 alpha-2 country code. */
|
|
countryIso: text('country_iso'),
|
|
isPrimary: boolean('is_primary').notNull().default(true),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_ca_client').on(table.clientId),
|
|
index('idx_ca_port').on(table.portId),
|
|
uniqueIndex('idx_ca_primary')
|
|
.on(table.clientId)
|
|
.where(sql`${table.isPrimary} = true`),
|
|
],
|
|
);
|
|
|
|
export type Client = typeof clients.$inferSelect;
|
|
export type NewClient = typeof clients.$inferInsert;
|
|
export type ClientContact = typeof clientContacts.$inferSelect;
|
|
export type NewClientContact = typeof clientContacts.$inferInsert;
|
|
export type ClientRelationship = typeof clientRelationships.$inferSelect;
|
|
export type NewClientRelationship = typeof clientRelationships.$inferInsert;
|
|
export type ClientNote = typeof clientNotes.$inferSelect;
|
|
export type NewClientNote = typeof clientNotes.$inferInsert;
|
|
export type ClientMergeLog = typeof clientMergeLog.$inferSelect;
|
|
export type NewClientMergeLog = typeof clientMergeLog.$inferInsert;
|
|
export type ClientAddress = typeof clientAddresses.$inferSelect;
|
|
export type NewClientAddress = typeof clientAddresses.$inferInsert;
|
|
export type ClientMergeCandidate = typeof clientMergeCandidates.$inferSelect;
|
|
export type NewClientMergeCandidate = typeof clientMergeCandidates.$inferInsert;
|