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
170 lines
7.1 KiB
TypeScript
170 lines
7.1 KiB
TypeScript
import { boolean, pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
|
|
import { ports } from './ports';
|
|
|
|
/**
|
|
* Residential clients - physically separated from `clients` because the
|
|
* residential side is handled by an external team that should never see
|
|
* marina-side data, and vice versa. The two domains share a port but no
|
|
* tables, so the access boundary is enforced at the schema level.
|
|
*/
|
|
export const residentialClients = pgTable(
|
|
'residential_clients',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
fullName: text('full_name').notNull(),
|
|
email: text('email'),
|
|
phone: text('phone'),
|
|
/** E.164-normalized phone, populated alongside `phone` once the i18n
|
|
* PhoneInput component lands. The free-text `phone` column stays
|
|
* for one release as a fallback for unparseable rows. */
|
|
phoneE164: text('phone_e164'),
|
|
/** ISO-3166-1 alpha-2 - country the phone was parsed against. */
|
|
phoneCountry: text('phone_country'),
|
|
/** ISO-3166-1 alpha-2 nationality. */
|
|
nationalityIso: text('nationality_iso'),
|
|
/** IANA timezone for scheduling/reminders. */
|
|
timezone: text('timezone'),
|
|
placeOfResidence: text('place_of_residence'),
|
|
/** ISO-3166-1 alpha-2 country of residence. */
|
|
placeOfResidenceCountryIso: text('place_of_residence_country_iso'),
|
|
/** ISO 3166-2 subdivision code for place of residence. Optional. */
|
|
subdivisionIso: text('subdivision_iso'),
|
|
preferredContactMethod: text('preferred_contact_method'), // email | phone
|
|
/**
|
|
* Lifecycle: prospect | active | inactive. Distinct from
|
|
* pipeline_stage on residential_interests (which is per-inquiry).
|
|
*/
|
|
status: text('status').notNull().default('prospect'),
|
|
source: text('source'), // website | manual | referral | broker
|
|
notes: text('notes'),
|
|
/**
|
|
* 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
|
|
* record outlives a GDPR wipe of the main client. Migration 0080
|
|
* adds the FK + supporting index.
|
|
*/
|
|
linkedClientId: text('linked_client_id'),
|
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_residential_clients_port').on(table.portId),
|
|
index('idx_residential_clients_email').on(table.email),
|
|
index('idx_residential_clients_archived')
|
|
.on(table.portId)
|
|
.where(sql`${table.archivedAt} IS NULL`),
|
|
index('idx_residential_clients_linked_client')
|
|
.on(table.linkedClientId)
|
|
.where(sql`${table.linkedClientId} IS NOT NULL`),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Residential interests - one per inquiry/lead. A residential_client can
|
|
* have multiple interests over time (e.g. inquired about a unit in 2025,
|
|
* came back about a different unit in 2026).
|
|
*
|
|
* Pipeline stages: new | contacted | viewing_scheduled | offer_made |
|
|
* offer_accepted | closed_won | closed_lost.
|
|
*/
|
|
export const residentialInterests = pgTable(
|
|
'residential_interests',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
residentialClientId: text('residential_client_id')
|
|
.notNull()
|
|
.references(() => residentialClients.id, { onDelete: 'cascade' }),
|
|
pipelineStage: text('pipeline_stage').notNull().default('new'),
|
|
source: text('source'), // website | manual | referral | broker
|
|
notes: text('notes'),
|
|
/**
|
|
* Free-text capture of unit-type / size / floor / budget preferences -
|
|
* residential leads are exploratory and the external team uses notes
|
|
* heavily. Schema can grow into structured columns later if needed.
|
|
*/
|
|
preferences: text('preferences'),
|
|
/**
|
|
* better-auth user id of the residential team member working this lead.
|
|
*/
|
|
assignedTo: text('assigned_to'),
|
|
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
|
|
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
|
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_residential_interests_port').on(table.portId),
|
|
index('idx_residential_interests_client').on(table.residentialClientId),
|
|
index('idx_residential_interests_stage').on(table.portId, table.pipelineStage),
|
|
index('idx_residential_interests_assigned').on(table.assignedTo),
|
|
index('idx_residential_interests_archived')
|
|
.on(table.portId)
|
|
.where(sql`${table.archivedAt} IS NULL`),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Threaded notes for residential clients - mirror the marina-side
|
|
* `clientNotes` shape so the polymorphic NotesList component works
|
|
* with `entityType='residential_clients'`.
|
|
*/
|
|
export const residentialClientNotes = pgTable(
|
|
'residential_client_notes',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
residentialClientId: text('residential_client_id')
|
|
.notNull()
|
|
.references(() => residentialClients.id, { onDelete: 'cascade' }),
|
|
authorId: text('author_id').notNull(),
|
|
content: text('content').notNull(),
|
|
mentions: text('mentions').array(),
|
|
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_rcn_client').on(table.residentialClientId)],
|
|
);
|
|
|
|
export const residentialInterestNotes = pgTable(
|
|
'residential_interest_notes',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
residentialInterestId: text('residential_interest_id')
|
|
.notNull()
|
|
.references(() => residentialInterests.id, { onDelete: 'cascade' }),
|
|
authorId: text('author_id').notNull(),
|
|
content: text('content').notNull(),
|
|
mentions: text('mentions').array(),
|
|
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_rin_interest').on(table.residentialInterestId)],
|
|
);
|
|
|
|
export type ResidentialClient = typeof residentialClients.$inferSelect;
|
|
export type NewResidentialClient = typeof residentialClients.$inferInsert;
|
|
export type ResidentialInterest = typeof residentialInterests.$inferSelect;
|
|
export type NewResidentialInterest = typeof residentialInterests.$inferInsert;
|
|
export type ResidentialClientNote = typeof residentialClientNotes.$inferSelect;
|
|
export type ResidentialInterestNote = typeof residentialInterestNotes.$inferSelect;
|