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:
61
src/lib/db/migrations/0028_interest_berths_junction.sql
Normal file
61
src/lib/db/migrations/0028_interest_berths_junction.sql
Normal 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;
|
||||||
10892
src/lib/db/migrations/meta/0028_snapshot.json
Normal file
10892
src/lib/db/migrations/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
|||||||
"when": 1777939914252,
|
"when": 1777939914252,
|
||||||
"tag": "0027_backfill_nationality_iso_from_phone",
|
"tag": "0027_backfill_nationality_iso_from_phone",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777940421236,
|
||||||
|
"tag": "0028_interest_berths_junction",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ports } from './ports';
|
||||||
import { clients } from './clients';
|
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
|
// 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'. */
|
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
|
||||||
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
|
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
|
||||||
notes: text('notes'),
|
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 }),
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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(
|
export const interestNotes = pgTable(
|
||||||
'interest_notes',
|
'interest_notes',
|
||||||
{
|
{
|
||||||
@@ -96,3 +166,5 @@ export type Interest = typeof interests.$inferSelect;
|
|||||||
export type NewInterest = typeof interests.$inferInsert;
|
export type NewInterest = typeof interests.$inferInsert;
|
||||||
export type InterestNote = typeof interestNotes.$inferSelect;
|
export type InterestNote = typeof interestNotes.$inferSelect;
|
||||||
export type NewInterestNote = typeof interestNotes.$inferInsert;
|
export type NewInterestNote = typeof interestNotes.$inferInsert;
|
||||||
|
export type InterestBerth = typeof interestBerths.$inferSelect;
|
||||||
|
export type NewInterestBerth = typeof interestBerths.$inferInsert;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from './clients';
|
} from './clients';
|
||||||
|
|
||||||
// Interests
|
// Interests
|
||||||
import { interests, interestNotes, interestTags } from './interests';
|
import { interests, interestNotes, interestTags, interestBerths } from './interests';
|
||||||
|
|
||||||
// Yachts
|
// Yachts
|
||||||
import { yachts, yachtOwnershipHistory, yachtNotes, yachtTags } from './yachts';
|
import { yachts, yachtOwnershipHistory, yachtNotes, yachtTags } from './yachts';
|
||||||
@@ -274,6 +274,18 @@ export const interestsRelations = relations(interests, ({ one, many }) => ({
|
|||||||
reminders: many(reminders),
|
reminders: many(reminders),
|
||||||
berthRecommendations: many(berthRecommendations),
|
berthRecommendations: many(berthRecommendations),
|
||||||
formSubmissions: many(formSubmissions),
|
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 }) => ({
|
export const interestNotesRelations = relations(interestNotes, ({ one }) => ({
|
||||||
|
|||||||
203
src/lib/services/interest-berths.service.ts
Normal file
203
src/lib/services/interest-berths.service.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* interest_berths junction helpers.
|
||||||
|
*
|
||||||
|
* The junction is the source of truth for which berths an interest is
|
||||||
|
* linked to. Callers should resolve "the berth for this deal" through
|
||||||
|
* `getPrimaryBerth(interestId)` rather than reading the legacy
|
||||||
|
* `interests.berth_id` column (slated for removal once every caller
|
||||||
|
* is migrated - see plan §3.4).
|
||||||
|
*
|
||||||
|
* Role-flag semantics (see plan §1):
|
||||||
|
* - is_primary : at most one row per interest. Templates,
|
||||||
|
* forms, and "the berth for this deal"
|
||||||
|
* UIs resolve through this row.
|
||||||
|
* - is_specific_interest : the 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { interestBerths, type InterestBerth } from '@/lib/db/schema/interests';
|
||||||
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
|
|
||||||
|
// ─── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PrimaryBerthRef {
|
||||||
|
berthId: string;
|
||||||
|
mooringNumber: string | null;
|
||||||
|
isInEoiBundle: boolean;
|
||||||
|
isSpecificInterest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The primary berth for an interest, if any. Resolves the row marked
|
||||||
|
* `is_primary=true`; falls back to the most recently added berth row
|
||||||
|
* when no row is flagged primary (defensive — the unique partial index
|
||||||
|
* guarantees ≤1 primary, but reads should never throw on data drift).
|
||||||
|
*/
|
||||||
|
export async function getPrimaryBerth(interestId: string): Promise<PrimaryBerthRef | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
berthId: interestBerths.berthId,
|
||||||
|
isPrimary: interestBerths.isPrimary,
|
||||||
|
isSpecificInterest: interestBerths.isSpecificInterest,
|
||||||
|
isInEoiBundle: interestBerths.isInEoiBundle,
|
||||||
|
addedAt: interestBerths.addedAt,
|
||||||
|
mooringNumber: berths.mooringNumber,
|
||||||
|
})
|
||||||
|
.from(interestBerths)
|
||||||
|
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||||
|
.where(eq(interestBerths.interestId, interestId))
|
||||||
|
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
|
||||||
|
const first = rows[0];
|
||||||
|
if (!first) return null;
|
||||||
|
return {
|
||||||
|
berthId: first.berthId,
|
||||||
|
mooringNumber: first.mooringNumber,
|
||||||
|
isInEoiBundle: first.isInEoiBundle,
|
||||||
|
isSpecificInterest: first.isSpecificInterest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map { interestId → primary berth ref } for a batch of interest ids.
|
||||||
|
* One round-trip; preferred for list pages over a per-row helper.
|
||||||
|
*/
|
||||||
|
export async function getPrimaryBerthsForInterests(
|
||||||
|
interestIds: string[],
|
||||||
|
): Promise<Map<string, PrimaryBerthRef>> {
|
||||||
|
if (interestIds.length === 0) return new Map();
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
interestId: interestBerths.interestId,
|
||||||
|
berthId: interestBerths.berthId,
|
||||||
|
isPrimary: interestBerths.isPrimary,
|
||||||
|
isSpecificInterest: interestBerths.isSpecificInterest,
|
||||||
|
isInEoiBundle: interestBerths.isInEoiBundle,
|
||||||
|
addedAt: interestBerths.addedAt,
|
||||||
|
mooringNumber: berths.mooringNumber,
|
||||||
|
})
|
||||||
|
.from(interestBerths)
|
||||||
|
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||||
|
.where(sql`${interestBerths.interestId} = ANY(${interestIds})`)
|
||||||
|
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
|
||||||
|
|
||||||
|
const out = new Map<string, PrimaryBerthRef>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (out.has(r.interestId)) continue;
|
||||||
|
out.set(r.interestId, {
|
||||||
|
berthId: r.berthId,
|
||||||
|
mooringNumber: r.mooringNumber,
|
||||||
|
isInEoiBundle: r.isInEoiBundle,
|
||||||
|
isSpecificInterest: r.isSpecificInterest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All berth links for a single interest, ordered with primary first. */
|
||||||
|
export async function listBerthsForInterest(
|
||||||
|
interestId: string,
|
||||||
|
): Promise<Array<InterestBerth & { mooringNumber: string | null }>> {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: interestBerths.id,
|
||||||
|
interestId: interestBerths.interestId,
|
||||||
|
berthId: interestBerths.berthId,
|
||||||
|
isPrimary: interestBerths.isPrimary,
|
||||||
|
isSpecificInterest: interestBerths.isSpecificInterest,
|
||||||
|
isInEoiBundle: interestBerths.isInEoiBundle,
|
||||||
|
eoiBypassReason: interestBerths.eoiBypassReason,
|
||||||
|
eoiBypassedBy: interestBerths.eoiBypassedBy,
|
||||||
|
eoiBypassedAt: interestBerths.eoiBypassedAt,
|
||||||
|
addedBy: interestBerths.addedBy,
|
||||||
|
addedAt: interestBerths.addedAt,
|
||||||
|
notes: interestBerths.notes,
|
||||||
|
mooringNumber: berths.mooringNumber,
|
||||||
|
})
|
||||||
|
.from(interestBerths)
|
||||||
|
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||||
|
.where(eq(interestBerths.interestId, interestId))
|
||||||
|
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All interest links for a single berth (used by the recommender + admin UI). */
|
||||||
|
export async function listInterestsForBerth(berthId: string): Promise<Array<InterestBerth>> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(interestBerths)
|
||||||
|
.where(eq(interestBerths.berthId, berthId))
|
||||||
|
.orderBy(desc(interestBerths.addedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Writes ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AddOrUpdateOpts {
|
||||||
|
isPrimary?: boolean;
|
||||||
|
isSpecificInterest?: boolean;
|
||||||
|
isInEoiBundle?: boolean;
|
||||||
|
addedBy?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently link a berth to an interest. If the row already exists,
|
||||||
|
* provided flags are merged; otherwise a fresh row is inserted.
|
||||||
|
*
|
||||||
|
* When `isPrimary=true` is requested, the previous primary (if any) is
|
||||||
|
* demoted in the same transaction so the unique partial index is never
|
||||||
|
* violated.
|
||||||
|
*/
|
||||||
|
export async function upsertInterestBerth(
|
||||||
|
interestId: string,
|
||||||
|
berthId: string,
|
||||||
|
opts: AddOrUpdateOpts = {},
|
||||||
|
): Promise<InterestBerth> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
if (opts.isPrimary === true) {
|
||||||
|
await tx
|
||||||
|
.update(interestBerths)
|
||||||
|
.set({ isPrimary: false })
|
||||||
|
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.isPrimary, true)));
|
||||||
|
}
|
||||||
|
const setForUpdate: Partial<InterestBerth> = {};
|
||||||
|
if (opts.isPrimary !== undefined) setForUpdate.isPrimary = opts.isPrimary;
|
||||||
|
if (opts.isSpecificInterest !== undefined)
|
||||||
|
setForUpdate.isSpecificInterest = opts.isSpecificInterest;
|
||||||
|
if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle;
|
||||||
|
if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy;
|
||||||
|
if (opts.notes !== undefined) setForUpdate.notes = opts.notes;
|
||||||
|
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(interestBerths)
|
||||||
|
.values({
|
||||||
|
interestId,
|
||||||
|
berthId,
|
||||||
|
isPrimary: opts.isPrimary ?? false,
|
||||||
|
isSpecificInterest: opts.isSpecificInterest ?? true,
|
||||||
|
isInEoiBundle: opts.isInEoiBundle ?? false,
|
||||||
|
addedBy: opts.addedBy,
|
||||||
|
notes: opts.notes,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [interestBerths.interestId, interestBerths.berthId],
|
||||||
|
set: setForUpdate,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Promote a single berth to primary for the interest. Demotes any prior primary. */
|
||||||
|
export async function setPrimaryBerth(interestId: string, berthId: string): Promise<void> {
|
||||||
|
await upsertInterestBerth(interestId, berthId, { isPrimary: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a berth from an interest. */
|
||||||
|
export async function removeInterestBerth(interestId: string, berthId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(interestBerths)
|
||||||
|
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user