Files
pn-new-crm/src/lib/db/schema/residential.ts
Matt 221ae5784e 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
2026-05-23 00:52:59 +02:00

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;