Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
7.1 KiB
TypeScript
169 lines
7.1 KiB
TypeScript
import {
|
|
pgTable,
|
|
text,
|
|
boolean,
|
|
integer,
|
|
numeric,
|
|
timestamp,
|
|
primaryKey,
|
|
index,
|
|
uniqueIndex,
|
|
} from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
import { ports } from './ports';
|
|
import { clients } from './clients';
|
|
import { berths } from './berths';
|
|
|
|
// Pipeline stages: open, details_sent, in_communication, eoi_sent, eoi_signed, deposit_10pct, contract_sent, contract_signed, completed
|
|
|
|
export const interests = pgTable(
|
|
'interests',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id),
|
|
yachtId: text('yacht_id'), // FK added via relation; nullable until pipeline leaves 'open'
|
|
pipelineStage: text('pipeline_stage').notNull().default('open'),
|
|
leadCategory: text('lead_category'), // general_interest, specific_qualified, hot_lead
|
|
source: text('source'), // website, manual, referral, broker
|
|
eoiStatus: text('eoi_status'), // null, waiting_for_signatures, signed, expired
|
|
documensoId: text('documenso_id'),
|
|
contractStatus: text('contract_status'),
|
|
depositStatus: text('deposit_status'),
|
|
reservationStatus: text('reservation_status'),
|
|
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
|
|
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
|
|
dateEoiSent: timestamp('date_eoi_sent', { withTimezone: true }),
|
|
dateEoiSigned: timestamp('date_eoi_signed', { withTimezone: true }),
|
|
dateContractSent: timestamp('date_contract_sent', { withTimezone: true }),
|
|
dateContractSigned: timestamp('date_contract_signed', { withTimezone: true }),
|
|
dateDepositReceived: timestamp('date_deposit_received', { withTimezone: true }),
|
|
reminderEnabled: boolean('reminder_enabled').notNull().default(false),
|
|
reminderDays: integer('reminder_days'),
|
|
reminderLastFired: timestamp('reminder_last_fired', { withTimezone: true }),
|
|
/** Terminal outcome. Independent of pipelineStage - `outcome` is set
|
|
* alongside the stage transition to `completed` to distinguish won
|
|
* deals from the various lost variants. NULL while the interest is
|
|
* still active. */
|
|
outcome: text('outcome'), // 'won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled'
|
|
/** Free-text reason captured at the time the outcome is set. Surfaces
|
|
* in the timeline + reports. */
|
|
outcomeReason: text('outcome_reason'),
|
|
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
|
|
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
|
|
notes: text('notes'),
|
|
/** Recommender inputs - imperial; resolver treats nulls as "no constraint"
|
|
* on that axis, with a banner prompting the rep to add the missing dim. */
|
|
desiredLengthFt: numeric('desired_length_ft'),
|
|
desiredWidthFt: numeric('desired_width_ft'),
|
|
desiredDraftFt: numeric('desired_draft_ft'),
|
|
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_interests_port').on(table.portId),
|
|
index('idx_interests_client').on(table.clientId),
|
|
index('idx_interests_yacht').on(table.yachtId),
|
|
index('idx_interests_stage').on(table.portId, table.pipelineStage),
|
|
index('idx_interests_archived').on(table.portId, table.archivedAt),
|
|
index('idx_interests_outcome').on(table.portId, table.outcome),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Many-to-many junction between interests and berths.
|
|
*
|
|
* Replaces the old single-berth `interests.berth_id` column. Each row
|
|
* carries three role flags so a rep can model "actively pitching this
|
|
* berth" vs "covered by the EOI bundle but not pitched" vs "primary
|
|
* berth for the deal" independently:
|
|
*
|
|
* - is_primary : at most one row per interest is the primary;
|
|
* templates / forms / "the berth for this deal"
|
|
* semantics resolve through this row.
|
|
* - is_specific_interest : true = berth shows as "Under Offer" on the
|
|
* public map. false = legal/EOI-only link.
|
|
* - is_in_eoi_bundle : covered by the interest's EOI signature.
|
|
*
|
|
* EOI bypass: when the interest has a signed primary EOI but a specific
|
|
* berth in the bundle still needs its own EOI, a rep records the bypass
|
|
* reason here.
|
|
*/
|
|
export const interestBerths = pgTable(
|
|
'interest_berths',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
interestId: text('interest_id')
|
|
.notNull()
|
|
.references(() => interests.id, { onDelete: 'cascade' }),
|
|
berthId: text('berth_id')
|
|
.notNull()
|
|
.references(() => berths.id, { onDelete: 'restrict' }),
|
|
isPrimary: boolean('is_primary').notNull().default(false),
|
|
isSpecificInterest: boolean('is_specific_interest').notNull().default(true),
|
|
isInEoiBundle: boolean('is_in_eoi_bundle').notNull().default(false),
|
|
eoiBypassReason: text('eoi_bypass_reason'),
|
|
eoiBypassedBy: text('eoi_bypassed_by'),
|
|
eoiBypassedAt: timestamp('eoi_bypassed_at', { withTimezone: true }),
|
|
addedBy: text('added_by'),
|
|
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
|
notes: text('notes'),
|
|
},
|
|
(table) => [
|
|
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),
|
|
uniqueIndex('idx_ib_one_primary')
|
|
.on(table.interestId)
|
|
.where(sql`${table.isPrimary} = true`),
|
|
index('idx_ib_berth').on(table.berthId),
|
|
index('idx_ib_specific')
|
|
.on(table.berthId)
|
|
.where(sql`${table.isSpecificInterest} = true`),
|
|
],
|
|
);
|
|
|
|
export const interestNotes = pgTable(
|
|
'interest_notes',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
interestId: text('interest_id')
|
|
.notNull()
|
|
.references(() => interests.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_in_interest').on(table.interestId)],
|
|
);
|
|
|
|
export const interestTags = pgTable(
|
|
'interest_tags',
|
|
{
|
|
interestId: text('interest_id')
|
|
.notNull()
|
|
.references(() => interests.id, { onDelete: 'cascade' }),
|
|
tagId: text('tag_id').notNull(), // references tags.id
|
|
},
|
|
(table) => [primaryKey({ columns: [table.interestId, table.tagId] })],
|
|
);
|
|
|
|
export type Interest = typeof interests.$inferSelect;
|
|
export type NewInterest = typeof interests.$inferInsert;
|
|
export type InterestNote = typeof interestNotes.$inferSelect;
|
|
export type NewInterestNote = typeof interestNotes.$inferInsert;
|
|
export type InterestBerth = typeof interestBerths.$inferSelect;
|
|
export type NewInterestBerth = typeof interestBerths.$inferInsert;
|