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:
Matt Ciaccio
2026-05-05 02:22:11 +02:00
parent 05257723f6
commit ff92a08620
6 changed files with 11249 additions and 2 deletions

View File

@@ -0,0 +1,61 @@
CREATE TABLE "interest_berths" (
"id" text PRIMARY KEY NOT NULL,
"interest_id" text NOT NULL,
"berth_id" text NOT NULL,
"is_primary" boolean DEFAULT false NOT NULL,
"is_specific_interest" boolean DEFAULT true NOT NULL,
"is_in_eoi_bundle" boolean DEFAULT false NOT NULL,
"eoi_bypass_reason" text,
"eoi_bypassed_by" text,
"eoi_bypassed_at" timestamp with time zone,
"added_by" text,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
"notes" text
);
--> statement-breakpoint
ALTER TABLE "interests" ADD COLUMN "desired_length_ft" numeric;--> statement-breakpoint
ALTER TABLE "interests" ADD COLUMN "desired_width_ft" numeric;--> statement-breakpoint
ALTER TABLE "interests" ADD COLUMN "desired_draft_ft" numeric;--> statement-breakpoint
ALTER TABLE "interest_berths" ADD CONSTRAINT "interest_berths_interest_id_interests_id_fk" FOREIGN KEY ("interest_id") REFERENCES "public"."interests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "interest_berths" ADD CONSTRAINT "interest_berths_berth_id_berths_id_fk" FOREIGN KEY ("berth_id") REFERENCES "public"."berths"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_ib_interest_berth" ON "interest_berths" USING btree ("interest_id","berth_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_ib_one_primary" ON "interest_berths" USING btree ("interest_id") WHERE "interest_berths"."is_primary" = true;--> statement-breakpoint
CREATE INDEX "idx_ib_berth" ON "interest_berths" USING btree ("berth_id");--> statement-breakpoint
CREATE INDEX "idx_ib_specific" ON "interest_berths" USING btree ("berth_id") WHERE "interest_berths"."is_specific_interest" = true;--> statement-breakpoint
-- Pre-flight: halt if any interests.berth_id points at a row that no
-- longer exists. The new junction's FK is `restrict`, so a dangling
-- value would otherwise abort the insert mid-batch with a confusing
-- error.
DO $$
DECLARE
orphan_count integer;
BEGIN
SELECT count(*) INTO orphan_count
FROM interests i
LEFT JOIN berths b ON b.id = i.berth_id
WHERE i.berth_id IS NOT NULL
AND b.id IS NULL;
IF orphan_count > 0 THEN
RAISE EXCEPTION 'interests.berth_id has % dangling references; resolve manually before re-running', orphan_count;
END IF;
END $$;--> statement-breakpoint
-- Migrate existing interest.berth_id values into the junction. Every
-- pre-existing single-berth link becomes a primary, specific-interest
-- row. is_in_eoi_bundle = true only when the interest already has a
-- signed EOI (the legacy "the berth is contractually committed" case).
INSERT INTO interest_berths (
id, interest_id, berth_id,
is_primary, is_specific_interest, is_in_eoi_bundle,
added_at
)
SELECT
gen_random_uuid()::text,
i.id,
i.berth_id,
true,
true,
COALESCE(i.eoi_status = 'signed', false),
i.created_at
FROM interests i
WHERE i.berth_id IS NOT NULL
ON CONFLICT (interest_id, berth_id) DO NOTHING;

File diff suppressed because it is too large Load Diff

View File

@@ -197,6 +197,13 @@
"when": 1777939914252,
"tag": "0027_backfill_nationality_iso_from_phone",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1777940421236,
"tag": "0028_interest_berths_junction",
"breakpoints": true
}
]
}

View File

@@ -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;

View File

@@ -18,7 +18,7 @@ import {
} from './clients';
// Interests
import { interests, interestNotes, interestTags } from './interests';
import { interests, interestNotes, interestTags, interestBerths } from './interests';
// Yachts
import { yachts, yachtOwnershipHistory, yachtNotes, yachtTags } from './yachts';
@@ -274,6 +274,18 @@ export const interestsRelations = relations(interests, ({ one, many }) => ({
reminders: many(reminders),
berthRecommendations: many(berthRecommendations),
formSubmissions: many(formSubmissions),
interestBerths: many(interestBerths),
}));
export const interestBerthsRelations = relations(interestBerths, ({ one }) => ({
interest: one(interests, {
fields: [interestBerths.interestId],
references: [interests.id],
}),
berth: one(berths, {
fields: [interestBerths.berthId],
references: [berths.id],
}),
}));
export const interestNotesRelations = relations(interestNotes, ({ one }) => ({