feat(db): m:m interest_berths junction + role flags
Introduces the multi-berth interest model from plan §3.1: a junction between interests and berths with three role flags so the same berth can be linked as the primary deal target, an EOI-bundle inclusion, or a "just exploring" link without conflating semantics. - 0028 schema migration creates interest_berths with the unique partial index "≤1 primary per interest", a unique compound on (interest_id, berth_id), and indexes for the public-map "under offer" lookup (where is_specific_interest=true). - Same migration adds desired_length_ft / desired_width_ft / desired_draft_ft to interests for the recommender. - Same migration runs the Phase 2 data migration: every interest with a non-null berth_id gets one junction row marked is_primary=true, is_specific_interest=true, and is_in_eoi_bundle = (eoi_status='signed'). Pre-flight check halts on dangling FKs (§14.3 critical case). - New service src/lib/services/interest-berths.service.ts owns reads + writes of the junction. getPrimaryBerth / getPrimaryBerthsForInterests feed list pages; upsertInterestBerth demotes the prior primary in the same transaction so the unique index is never violated. - interests.berth_id stays in place this commit so existing callers keep working; Phase 2b migrates them onto the helper service and a later migration drops the column. 53 dev rows seeded into the junction; tests still green at 996. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,18 @@
|
||||
import { pgTable, text, boolean, integer, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||
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
|
||||
|
||||
@@ -47,6 +59,11 @@ export const interests = pgTable(
|
||||
/** 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(),
|
||||
@@ -62,6 +79,59 @@ export const interests = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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',
|
||||
{
|
||||
@@ -96,3 +166,5 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user