Files
pn-new-crm/src/lib/db/schema/interests.ts

171 lines
7.2 KiB
TypeScript
Raw Normal View History

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>
2026-05-05 02:22:11 +02:00
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';
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>
2026-05-05 02:22:11 +02:00
import { berths } from './berths';
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
import { yachts } from './yachts';
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
// 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),
fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints Closes the second wave of HIGH-priority audit findings: * fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can no longer pin a worker concurrency slot indefinitely. OpenAI client passes timeout: 30_000. ImapFlow gets socket / greeting / connection timeouts. * SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP, closes Socket.io, and disconnects Redis before exit; compose stop_grace_period bumped to 30s. Adds closeSocketServer() helper. * env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and filesystem.ts now reads from env (a typo can no longer silently disable the multi-node guard). * Per-port Documenso template + recipient IDs land in system_settings with env fallback (PortDocumensoConfig now exposes eoiTemplateId, clientRecipientId, developerRecipientId, approvalRecipientId). document-templates.ts uses the per-port config and threads portId into documensoGenerateFromTemplate(). * Migration 0042 wires the eleven HIGH-tier missing FK constraints (documents/files/interests/reminders/berth_waiting_list/ form_submissions) plus polymorphic CHECK round 2 (yacht_ownership_history.owner_type, document_sends.document_kind), invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK. Drizzle schema columns updated to .references(...) where possible so the misleading "FK wired in relations.ts" comments are gone. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 + MED §§14,15,16,18. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
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
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
* 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 }),
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>
2026-05-05 02:22:11 +02:00
/** 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),
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred items (deferring the Documenso Phases 2-7 build and items needing design decisions or live external instances). DB schema: - Migration 0046 converts 5 composite (port_id, archived_at) indexes to partial WHERE archived_at IS NULL — clients, interests, yachts, and both residential tables. Smaller, faster planner choice for the dominant list-query shape. Multi-tenant isolation: - document_sends now verifies recipient.interestId belongs to the port before landing on the audit row (the surrounding clientId check was already port-scoped; interestId pollution was the gap). Routes / API: - /api/v1/custom-fields/[entityId] requires entityType query param and gates on the matching resource permission (clients/interests/berths/ yachts/companies). Fixes the cross-resource gap where a user with clients.view could read company custom-field values. - Admin user list trash button wrapped in PermissionGate (edit was already gated; remove was not). Service polish: - berth-recommender accepts string-shaped JSONB booleans ('true'/'false') so admin UIs that wrap values as strings don't silently fall through to defaults. - expense-pdf renderReceiptHeader anchors all text positions to a captured baseY rather than reading mutating doc.y after rect+stroke. Headers no longer drift on the first receipt page after a soft page break. - berth-pdf apply: collect non-finite numeric coercion drops + warn-log them so partial silent drops are observable (was invisible because the no-fields-supplied check only fires when ALL drop). - Storage cache fingerprint comment documenting the encrypted-secret invariant + the explicit invalidation hook. UI polish: - invoice-detail typed: replaced two `any` casts with a proper InvoiceDetailData / LineItem / LinkedExpense interface set. - YachtForm now accepts initialOwner prop. Wired through: - client-yachts-tab passes { type: 'client', id: clientId } - interest-form passes { type: 'client', id: selectedClientId } - Interest-form yacht picker now includes company-owned yachts where the selected client is a member (fetches client.companies and feeds YachtPicker an array filter). Plus an inline "Add new" button that opens YachtForm pre-bound to the client. - YachtPicker accepts ownerFilter as single OR array for "match any" semantics. BACKLOG.md updated with what landed vs what's still deferred (and why each deferred item is genuinely larger than this push warrants). Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
index('idx_interests_archived')
.on(table.portId)
.where(sql`${table.archivedAt} IS NULL`),
feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes Three independent strengthenings of the sales spine that the prior coherence sweep made it possible to do cleanly. 1. EOI queue page - Sidebar entry under Documents → "EOI queue". - Route /[port]/documents/eoi renders DocumentsHub with the existing eoi_queue tab pre-selected (filters in-flight EOIs only). - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi route is no longer silently excluded. 2. Invoice ↔ deposit link - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind ('general' | 'deposit'). Indexed on (port_id, interest_id). - createInvoiceSchema requires interestId when kind === 'deposit'; the service validates the linked interest belongs to the same port before insert. - recordPayment auto-advances pipelineStage to deposit_10pct (via advanceStageIfBehind) when a paid invoice is kind=deposit and has an interestId. No-op if the interest is already further along. - "Create deposit invoice" link added to the Deposit milestone on the interest detail. Links to /invoices/new?interestId=…&kind=deposit; the form prefills the billing entity from the linked interest's client and shows a context banner. 3. Won / lost terminal outcomes - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified' | 'lost_no_response' | 'cancelled') + outcomeReason text + outcomeAt timestamp. Indexed on (port_id, outcome). - setInterestOutcome / clearInterestOutcome services + POST/DELETE /api/v1/interests/:id/outcome endpoints (gated by change_stage permission). Setting an outcome moves the interest to `completed` in the same write; clearing reopens to `in_communication` (or a caller-specified stage). - Mark Won / Mark Lost icon buttons on the interest detail header, plus an outcome badge that replaces the stage pill once a terminal outcome is set, plus a Reopen button. - Funnel + dashboard math updated to exclude lost/cancelled outcomes from active calculations (KPIs.activeInterests, pipelineValueUsd, getPipelineCounts, computePipelineFunnel, getRevenueForecast). The funnel now also returns a `lost` summary so callers can surface leakage without polluting conversion percentages. Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB manually via psql because drizzle-kit push hits a pre-existing zod parsing issue on the companies index. Dev server may need a restart to flush prepared-statement caches. tsc clean. vitest 832/832 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
index('idx_interests_outcome').on(table.portId, table.outcome),
],
);
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>
2026-05-05 02:22:11 +02:00
/**
* 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;
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>
2026-05-05 02:22:11 +02:00
export type InterestBerth = typeof interestBerths.$inferSelect;
export type NewInterestBerth = typeof interestBerths.$inferInsert;