Files
pn-new-crm/src/lib/db/schema/interests.ts
Matt 6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00

201 lines
9.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';
import { yachts } from './yachts';
// Pipeline stages: enquiry, qualified, nurturing, eoi, reservation, deposit_paid, contract
// (doc sub-status carried on eoi_doc_status / reservation_doc_status / contract_doc_status)
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').references(() => yachts.id, { onDelete: 'set null' }),
/** Who owns this deal. Auto-assigned on create from system_settings
* `default_new_interest_owner`; reassignable via the interest header. */
assignedTo: text('assigned_to'),
pipelineStage: text('pipeline_stage').notNull().default('enquiry'),
/** Sub-status for the doc-signing stages. NULL while the deal hasn't
* reached the stage yet; 'pending' | 'sent' | 'signed' | 'declined' | 'voided'. */
eoiDocStatus: text('eoi_doc_status'),
reservationDocStatus: text('reservation_doc_status'),
contractDocStatus: text('contract_doc_status'),
/** Documenso IDs per document type. EOI uses the existing `documensoId`
* for backward compat with the template-generate path. */
reservationDocumensoId: text('reservation_documenso_id'),
contractDocumensoId: text('contract_documenso_id'),
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'),
/** Agreed deposit amount captured at reservation-agreement time. Lets
* the payments running-total decide when the deposit is "fully paid"
* and the stage advances automatically. */
depositExpectedAmount: numeric('deposit_expected_amount'),
depositExpectedCurrency: text('deposit_expected_currency').default('EUR'),
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 }),
dateReservationSigned: timestamp('date_reservation_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 }),
/** Recommender inputs - dual-stored. ft is the canonical unit the
* recommender SQL queries on; m is the human-friendly entry the rep
* may have actually typed. The matching `*_unit` column says which
* side is source-of-truth — display prefers that side and recomputes
* the other so the rep's literal entry doesn't drift through repeated
* conversions. Resolver treats nulls as "no constraint" on that axis. */
desiredLengthFt: numeric('desired_length_ft'),
desiredWidthFt: numeric('desired_width_ft'),
desiredDraftFt: numeric('desired_draft_ft'),
desiredLengthM: numeric('desired_length_m'),
desiredWidthM: numeric('desired_width_m'),
desiredDraftM: numeric('desired_draft_m'),
desiredLengthUnit: text('desired_length_unit').notNull().default('ft'),
desiredWidthUnit: text('desired_width_unit').notNull().default('ft'),
desiredDraftUnit: text('desired_draft_unit').notNull().default('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)
.where(sql`${table.archivedAt} IS NULL`),
index('idx_interests_outcome').on(table.portId, table.outcome),
index('idx_interests_assigned_to').on(table.assignedTo),
],
);
/**
* 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;