Files
pn-new-crm/src/lib/services/interests.service.ts

1032 lines
34 KiB
TypeScript
Raw Normal View History

refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
import { and, desc, eq, exists, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6, #7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real assignee column on interests, defer until ownership model lands) and #12 (keyboard shortcuts — explicit user call). Interest list (the rep's main triage surface): - Last activity column replaces Created (sortable by dateLastContact). Postgres NULLs-last on DESC means never-contacted leads sort to the bottom — exactly the right triage default. - Comment-icon next to client name when notesCount > 0, with a tooltip showing the count. Cheap, glanceable signal that the lead has correspondence to peek at. - Urgency badges under stage when criteria fire: "Silent Nd" for mid-funnel interests with no contact in 7+ days, "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd" for eoi_signed interests with no deposit after 21 days. Pure derived — no extra fetch, computed from the dates the row already returns. - Bulk select checkbox column with bulk-archive (existing DataTable.bulkActions API; just wired with a confirm-dialog and a Promise.all fan-out). - Mobile FAB (+) for new interest, anchored above the bottom-tab bar with safe-area inset awareness. All four signals mirrored on the mobile InterestCard (comment icon, urgency badges, last-activity footer). Interest detail: - Reminder bell badge in the header showing pending/snoozed reminder count linked to the interest. Surfaced via getInterestById's new `activeReminderCount`. - "Latest note" teaser on the Overview tab — truncated 3-line preview of the most recent threaded note + relative time + "View all" link to the Notes tab. Saves a click for the common "what was discussed last?" peek. - Color-block swatches in InlineStagePicker dropdown (rounded-sm mini-bars in the stage's progressive saturation color, replacing the previous tiny dots). Reads as a visual scan instead of a list. Dashboard: - MyRemindersRail on the right sidebar above the existing AlertRail. Shows pending+snoozed reminders for the current user (overdue first), each with priority pill, relative due time, and click-through to the linked interest/client/berth. Berth detail: - BerthInterestPulse card at the top of the Overview tab, replacing the old "buried in tab" pattern. Shows up to 5 active interests with avatar, stage pill, urgency badges, and last-activity. Mirrors the old Nuxt CRM's beloved "Interested Parties" panel but with the new triage signals. Realtime toasts: - New <RealtimeToasts /> mounted inside SocketProvider in the dashboard layout. Subscribes to interest:stageChanged, document:completed, document:signer:signed, and interest:outcomeSet — fires sonner toasts so reps watching any page learn about pipeline events without refreshing. Service layer: - listInterests: notesCount per row (left join + count + groupBy). - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164 (for the Email/Call/WhatsApp buttons added last commit; phone pieces were missing), notesCount, recentNote, activeReminderCount. - sortColumn switch handles 'dateLastContact' explicitly; default stays 'updatedAt'. tsc clean. vitest 835/835 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
import { reminders } from '@/lib/db/schema/operations';
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
import {
getPrimaryBerth,
getPrimaryBerthsForInterests,
removeInterestBerth,
upsertInterestBerth,
upsertInterestBerthTx,
} from '@/lib/services/interest-berths.service';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
import { PIPELINE_STAGES, canTransitionStage, type PipelineStage } from '@/lib/constants';
import type {
CreateInterestInput,
UpdateInterestInput,
ChangeStageInput,
ListInterestsInput,
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
SetOutcomeInput,
ClearOutcomeInput,
} from '@/lib/validators/interests';
// ─── Types ────────────────────────────────────────────────────────────────────
// ─── Port-scope FK validator ─────────────────────────────────────────────────
// Tenant scope: every FK referenced from an interest body - clientId, berthId,
// and yachtId - must belong to the caller's port. Without this, a body-supplied
// foreign-port id would create an interest that joins through these FKs and
// surfaces foreign-tenant data on subsequent reads (clientName, berth mooring
// number, yacht ownership). assertYachtBelongsToClient still runs separately to
// enforce the additional ownership invariant.
async function assertInterestFksInPort(
portId: string,
fks: { clientId?: string | null; berthId?: string | null; yachtId?: string | null },
): Promise<void> {
const checks: Array<Promise<void>> = [];
if (fks.clientId) {
checks.push(
db.query.clients
.findFirst({ where: and(eq(clients.id, fks.clientId), eq(clients.portId, portId)) })
.then((row) => {
if (!row) throw new ValidationError('clientId not found in this port');
}),
);
}
if (fks.berthId) {
checks.push(
db.query.berths
.findFirst({ where: and(eq(berths.id, fks.berthId), eq(berths.portId, portId)) })
.then((row) => {
if (!row) throw new ValidationError('berthId not found in this port');
}),
);
}
if (fks.yachtId) {
checks.push(
db.query.yachts
.findFirst({ where: and(eq(yachts.id, fks.yachtId), eq(yachts.portId, portId)) })
.then((row) => {
if (!row) throw new ValidationError('yachtId not found in this port');
}),
);
}
await Promise.all(checks);
}
// ─── Yacht ownership validator ───────────────────────────────────────────────
async function assertYachtBelongsToClient(
portId: string,
yachtId: string,
clientId: string,
): Promise<void> {
const yacht = await db.query.yachts.findFirst({
where: and(eq(yachts.id, yachtId), eq(yachts.portId, portId)),
});
if (!yacht) throw new ValidationError('yacht not found');
// Direct ownership by client
if (yacht.currentOwnerType === 'client' && yacht.currentOwnerId === clientId) {
return;
}
// Company-represented: client has active membership in the owning company
if (yacht.currentOwnerType === 'company') {
const membership = await db.query.companyMemberships.findFirst({
where: and(
eq(companyMemberships.companyId, yacht.currentOwnerId),
eq(companyMemberships.clientId, clientId),
isNull(companyMemberships.endDate),
),
});
if (membership) return;
}
throw new ValidationError('yacht does not belong to this client');
}
// ─── BR-011: Auto-promote leadCategory ───────────────────────────────────────
async function resolveLeadCategory(
clientId: string,
leadCategory: string | undefined | null,
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
yachtId?: string | null,
): Promise<string | undefined> {
if (leadCategory && leadCategory !== 'general_interest') {
return leadCategory;
}
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
if (yachtId) {
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
if (yacht && (yacht.lengthFt || yacht.lengthM)) {
return 'specific_qualified';
}
}
return leadCategory ?? undefined;
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listInterests(portId: string, query: ListInterestsInput) {
const {
page,
limit,
sort,
order,
search,
includeArchived,
clientId,
yachtId,
berthId,
pipelineStage,
leadCategory,
eoiStatus,
tagIds,
} = query;
const filters = [];
if (clientId) {
filters.push(eq(interests.clientId, clientId));
}
if (yachtId) {
filters.push(eq(interests.yachtId, yachtId));
}
if (berthId) {
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// EXISTS subquery against the junction: matches whether or not the
// berth is the interest's primary, mirroring "this berth is linked
// to this interest in any role" semantics from plan §3.4.
filters.push(
exists(
db
.select({ one: sql`1` })
.from(interestBerths)
.where(
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.berthId, berthId)),
),
),
);
}
if (pipelineStage && pipelineStage.length > 0) {
filters.push(inArray(interests.pipelineStage, pipelineStage));
}
if (leadCategory) {
filters.push(eq(interests.leadCategory, leadCategory));
}
if (eoiStatus) {
filters.push(eq(interests.eoiStatus, eoiStatus));
}
if (tagIds && tagIds.length > 0) {
const interestsWithTags = await db
.selectDistinct({ interestId: interestTags.interestId })
.from(interestTags)
.where(inArray(interestTags.tagId, tagIds));
const matchingIds = interestsWithTags.map((r) => r.interestId);
if (matchingIds.length > 0) {
filters.push(inArray(interests.id, matchingIds));
} else {
return { data: [], total: 0 };
}
}
const sortColumn = (() => {
switch (sort) {
case 'pipelineStage':
return interests.pipelineStage;
case 'leadCategory':
return interests.leadCategory;
case 'createdAt':
return interests.createdAt;
feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6, #7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real assignee column on interests, defer until ownership model lands) and #12 (keyboard shortcuts — explicit user call). Interest list (the rep's main triage surface): - Last activity column replaces Created (sortable by dateLastContact). Postgres NULLs-last on DESC means never-contacted leads sort to the bottom — exactly the right triage default. - Comment-icon next to client name when notesCount > 0, with a tooltip showing the count. Cheap, glanceable signal that the lead has correspondence to peek at. - Urgency badges under stage when criteria fire: "Silent Nd" for mid-funnel interests with no contact in 7+ days, "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd" for eoi_signed interests with no deposit after 21 days. Pure derived — no extra fetch, computed from the dates the row already returns. - Bulk select checkbox column with bulk-archive (existing DataTable.bulkActions API; just wired with a confirm-dialog and a Promise.all fan-out). - Mobile FAB (+) for new interest, anchored above the bottom-tab bar with safe-area inset awareness. All four signals mirrored on the mobile InterestCard (comment icon, urgency badges, last-activity footer). Interest detail: - Reminder bell badge in the header showing pending/snoozed reminder count linked to the interest. Surfaced via getInterestById's new `activeReminderCount`. - "Latest note" teaser on the Overview tab — truncated 3-line preview of the most recent threaded note + relative time + "View all" link to the Notes tab. Saves a click for the common "what was discussed last?" peek. - Color-block swatches in InlineStagePicker dropdown (rounded-sm mini-bars in the stage's progressive saturation color, replacing the previous tiny dots). Reads as a visual scan instead of a list. Dashboard: - MyRemindersRail on the right sidebar above the existing AlertRail. Shows pending+snoozed reminders for the current user (overdue first), each with priority pill, relative due time, and click-through to the linked interest/client/berth. Berth detail: - BerthInterestPulse card at the top of the Overview tab, replacing the old "buried in tab" pattern. Shows up to 5 active interests with avatar, stage pill, urgency badges, and last-activity. Mirrors the old Nuxt CRM's beloved "Interested Parties" panel but with the new triage signals. Realtime toasts: - New <RealtimeToasts /> mounted inside SocketProvider in the dashboard layout. Subscribes to interest:stageChanged, document:completed, document:signer:signed, and interest:outcomeSet — fires sonner toasts so reps watching any page learn about pipeline events without refreshing. Service layer: - listInterests: notesCount per row (left join + count + groupBy). - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164 (for the Email/Call/WhatsApp buttons added last commit; phone pieces were missing), notesCount, recentNote, activeReminderCount. - sortColumn switch handles 'dateLastContact' explicitly; default stays 'updatedAt'. tsc clean. vitest 835/835 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
case 'dateLastContact':
// Postgres sorts NULLs last on DESC by default, which is the right
// behaviour for triage (recently-contacted first, never-contacted
// at the bottom).
return interests.dateLastContact;
default:
return interests.updatedAt;
}
})();
const result = await buildListQuery({
table: interests,
portIdColumn: interests.portId,
portId,
idColumn: interests.id,
updatedAtColumn: interests.updatedAt,
filters,
sort: { column: sortColumn, direction: order },
page,
pageSize: limit,
searchColumns: [],
searchTerm: search,
includeArchived,
archivedAtColumn: interests.archivedAt,
});
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// Join client names, primary-berth mooring numbers, and yacht names.
const interestIds = (result.data as Array<{ id: string; clientId: string }>).map((i) => i.id);
const clientIds = [
...new Set((result.data as Array<{ clientId: string }>).map((i) => i.clientId)),
];
const yachtIds = [
...new Set(
(result.data as Array<{ yachtId: string | null }>)
.map((i) => i.yachtId)
.filter(Boolean) as string[],
),
];
let clientsMap: Record<string, string> = {};
let yachtsMap: Record<string, string> = {};
const tagsByInterestId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6, #7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real assignee column on interests, defer until ownership model lands) and #12 (keyboard shortcuts — explicit user call). Interest list (the rep's main triage surface): - Last activity column replaces Created (sortable by dateLastContact). Postgres NULLs-last on DESC means never-contacted leads sort to the bottom — exactly the right triage default. - Comment-icon next to client name when notesCount > 0, with a tooltip showing the count. Cheap, glanceable signal that the lead has correspondence to peek at. - Urgency badges under stage when criteria fire: "Silent Nd" for mid-funnel interests with no contact in 7+ days, "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd" for eoi_signed interests with no deposit after 21 days. Pure derived — no extra fetch, computed from the dates the row already returns. - Bulk select checkbox column with bulk-archive (existing DataTable.bulkActions API; just wired with a confirm-dialog and a Promise.all fan-out). - Mobile FAB (+) for new interest, anchored above the bottom-tab bar with safe-area inset awareness. All four signals mirrored on the mobile InterestCard (comment icon, urgency badges, last-activity footer). Interest detail: - Reminder bell badge in the header showing pending/snoozed reminder count linked to the interest. Surfaced via getInterestById's new `activeReminderCount`. - "Latest note" teaser on the Overview tab — truncated 3-line preview of the most recent threaded note + relative time + "View all" link to the Notes tab. Saves a click for the common "what was discussed last?" peek. - Color-block swatches in InlineStagePicker dropdown (rounded-sm mini-bars in the stage's progressive saturation color, replacing the previous tiny dots). Reads as a visual scan instead of a list. Dashboard: - MyRemindersRail on the right sidebar above the existing AlertRail. Shows pending+snoozed reminders for the current user (overdue first), each with priority pill, relative due time, and click-through to the linked interest/client/berth. Berth detail: - BerthInterestPulse card at the top of the Overview tab, replacing the old "buried in tab" pattern. Shows up to 5 active interests with avatar, stage pill, urgency badges, and last-activity. Mirrors the old Nuxt CRM's beloved "Interested Parties" panel but with the new triage signals. Realtime toasts: - New <RealtimeToasts /> mounted inside SocketProvider in the dashboard layout. Subscribes to interest:stageChanged, document:completed, document:signer:signed, and interest:outcomeSet — fires sonner toasts so reps watching any page learn about pipeline events without refreshing. Service layer: - listInterests: notesCount per row (left join + count + groupBy). - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164 (for the Email/Call/WhatsApp buttons added last commit; phone pieces were missing), notesCount, recentNote, activeReminderCount. - sortColumn switch handles 'dateLastContact' explicitly; default stays 'updatedAt'. tsc clean. vitest 835/835 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
const notesCountByInterestId: Record<string, number> = {};
if (clientIds.length > 0) {
const clientRows = await db
.select({ id: clients.id, fullName: clients.fullName })
.from(clients)
.where(inArray(clients.id, clientIds));
clientsMap = Object.fromEntries(clientRows.map((c) => [c.id, c.fullName]));
}
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// Primary-berth lookup via the interest_berths junction. Single round-trip
// by interestId list - see plan §3.4: every "the berth for this interest"
// surface resolves through getPrimaryBerth(...) rather than a column read.
const primaryBerthMap = await getPrimaryBerthsForInterests(interestIds);
if (yachtIds.length > 0) {
const yachtRows = await db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(inArray(yachts.id, yachtIds));
yachtsMap = Object.fromEntries(yachtRows.map((y) => [y.id, y.name]));
}
if (interestIds.length > 0) {
const tagRows = await db
.select({
interestId: interestTags.interestId,
id: tags.id,
name: tags.name,
color: tags.color,
})
.from(interestTags)
.innerJoin(tags, eq(interestTags.tagId, tags.id))
.where(inArray(interestTags.interestId, interestIds));
for (const row of tagRows) {
if (!tagsByInterestId[row.interestId]) tagsByInterestId[row.interestId] = [];
tagsByInterestId[row.interestId]!.push({ id: row.id, name: row.name, color: row.color });
}
feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6, #7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real assignee column on interests, defer until ownership model lands) and #12 (keyboard shortcuts — explicit user call). Interest list (the rep's main triage surface): - Last activity column replaces Created (sortable by dateLastContact). Postgres NULLs-last on DESC means never-contacted leads sort to the bottom — exactly the right triage default. - Comment-icon next to client name when notesCount > 0, with a tooltip showing the count. Cheap, glanceable signal that the lead has correspondence to peek at. - Urgency badges under stage when criteria fire: "Silent Nd" for mid-funnel interests with no contact in 7+ days, "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd" for eoi_signed interests with no deposit after 21 days. Pure derived — no extra fetch, computed from the dates the row already returns. - Bulk select checkbox column with bulk-archive (existing DataTable.bulkActions API; just wired with a confirm-dialog and a Promise.all fan-out). - Mobile FAB (+) for new interest, anchored above the bottom-tab bar with safe-area inset awareness. All four signals mirrored on the mobile InterestCard (comment icon, urgency badges, last-activity footer). Interest detail: - Reminder bell badge in the header showing pending/snoozed reminder count linked to the interest. Surfaced via getInterestById's new `activeReminderCount`. - "Latest note" teaser on the Overview tab — truncated 3-line preview of the most recent threaded note + relative time + "View all" link to the Notes tab. Saves a click for the common "what was discussed last?" peek. - Color-block swatches in InlineStagePicker dropdown (rounded-sm mini-bars in the stage's progressive saturation color, replacing the previous tiny dots). Reads as a visual scan instead of a list. Dashboard: - MyRemindersRail on the right sidebar above the existing AlertRail. Shows pending+snoozed reminders for the current user (overdue first), each with priority pill, relative due time, and click-through to the linked interest/client/berth. Berth detail: - BerthInterestPulse card at the top of the Overview tab, replacing the old "buried in tab" pattern. Shows up to 5 active interests with avatar, stage pill, urgency badges, and last-activity. Mirrors the old Nuxt CRM's beloved "Interested Parties" panel but with the new triage signals. Realtime toasts: - New <RealtimeToasts /> mounted inside SocketProvider in the dashboard layout. Subscribes to interest:stageChanged, document:completed, document:signer:signed, and interest:outcomeSet — fires sonner toasts so reps watching any page learn about pipeline events without refreshing. Service layer: - listInterests: notesCount per row (left join + count + groupBy). - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164 (for the Email/Call/WhatsApp buttons added last commit; phone pieces were missing), notesCount, recentNote, activeReminderCount. - sortColumn switch handles 'dateLastContact' explicitly; default stays 'updatedAt'. tsc clean. vitest 835/835 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
// Note counts per interest, for the comment-icon row affordance.
const noteCountRows = await db
.select({
interestId: interestNotes.interestId,
count: sql<number>`count(*)::int`,
})
.from(interestNotes)
.where(inArray(interestNotes.interestId, interestIds))
.groupBy(interestNotes.interestId);
for (const row of noteCountRows) {
notesCountByInterestId[row.interestId] = row.count;
}
}
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
const data = (result.data as Array<Record<string, unknown>>).map((i) => {
const primary = primaryBerthMap.get(i.id as string) ?? null;
return {
...i,
clientName: clientsMap[i.clientId as string] ?? null,
berthId: primary?.berthId ?? null,
berthMooringNumber: primary?.mooringNumber ?? null,
yachtName: i.yachtId ? (yachtsMap[i.yachtId as string] ?? null) : null,
tags: tagsByInterestId[i.id as string] ?? [],
notesCount: notesCountByInterestId[i.id as string] ?? 0,
};
});
return { data, total: result.total };
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
export async function getInterestById(id: string, portId: string) {
const interest = await db.query.interests.findFirst({
where: eq(interests.id, id),
});
if (!interest || interest.portId !== portId) {
throw new NotFoundError('Interest');
}
const [clientRow] = await db
.select({ fullName: clients.fullName })
.from(clients)
.where(eq(clients.id, interest.clientId));
// EOI prerequisites + interest-detail header contact actions: surface the
// linked client's primary email/phone (and the canonical E.164 form for
// wa.me) so the header can render Email / Call / WhatsApp buttons without
// a second fetch, and the Documents tab can show the EOI prereq checklist.
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
const [emailContact] = await db
.select({ value: clientContacts.value })
.from(clientContacts)
.where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email')))
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
.limit(1);
const [phoneContact] = await db
.select({ value: clientContacts.value, valueE164: clientContacts.valueE164 })
.from(clientContacts)
.where(
and(
eq(clientContacts.clientId, interest.clientId),
inArray(clientContacts.channel, ['phone', 'whatsapp']),
),
)
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
.limit(1);
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
const [addressRow] = await db
.select({ id: clientAddresses.id })
.from(clientAddresses)
.where(
and(eq(clientAddresses.clientId, interest.clientId), eq(clientAddresses.isPrimary, true)),
)
.limit(1);
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// Primary berth comes from the interest_berths junction (plan §3.4).
const primaryBerth = await getPrimaryBerth(interest.id);
const berthId = primaryBerth?.berthId ?? null;
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
const tagRows = await db
.select({ id: tags.id, name: tags.name, color: tags.color })
.from(interestTags)
.innerJoin(tags, eq(interestTags.tagId, tags.id))
.where(eq(interestTags.interestId, id));
feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6, #7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real assignee column on interests, defer until ownership model lands) and #12 (keyboard shortcuts — explicit user call). Interest list (the rep's main triage surface): - Last activity column replaces Created (sortable by dateLastContact). Postgres NULLs-last on DESC means never-contacted leads sort to the bottom — exactly the right triage default. - Comment-icon next to client name when notesCount > 0, with a tooltip showing the count. Cheap, glanceable signal that the lead has correspondence to peek at. - Urgency badges under stage when criteria fire: "Silent Nd" for mid-funnel interests with no contact in 7+ days, "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd" for eoi_signed interests with no deposit after 21 days. Pure derived — no extra fetch, computed from the dates the row already returns. - Bulk select checkbox column with bulk-archive (existing DataTable.bulkActions API; just wired with a confirm-dialog and a Promise.all fan-out). - Mobile FAB (+) for new interest, anchored above the bottom-tab bar with safe-area inset awareness. All four signals mirrored on the mobile InterestCard (comment icon, urgency badges, last-activity footer). Interest detail: - Reminder bell badge in the header showing pending/snoozed reminder count linked to the interest. Surfaced via getInterestById's new `activeReminderCount`. - "Latest note" teaser on the Overview tab — truncated 3-line preview of the most recent threaded note + relative time + "View all" link to the Notes tab. Saves a click for the common "what was discussed last?" peek. - Color-block swatches in InlineStagePicker dropdown (rounded-sm mini-bars in the stage's progressive saturation color, replacing the previous tiny dots). Reads as a visual scan instead of a list. Dashboard: - MyRemindersRail on the right sidebar above the existing AlertRail. Shows pending+snoozed reminders for the current user (overdue first), each with priority pill, relative due time, and click-through to the linked interest/client/berth. Berth detail: - BerthInterestPulse card at the top of the Overview tab, replacing the old "buried in tab" pattern. Shows up to 5 active interests with avatar, stage pill, urgency badges, and last-activity. Mirrors the old Nuxt CRM's beloved "Interested Parties" panel but with the new triage signals. Realtime toasts: - New <RealtimeToasts /> mounted inside SocketProvider in the dashboard layout. Subscribes to interest:stageChanged, document:completed, document:signer:signed, and interest:outcomeSet — fires sonner toasts so reps watching any page learn about pipeline events without refreshing. Service layer: - listInterests: notesCount per row (left join + count + groupBy). - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164 (for the Email/Call/WhatsApp buttons added last commit; phone pieces were missing), notesCount, recentNote, activeReminderCount. - sortColumn switch handles 'dateLastContact' explicitly; default stays 'updatedAt'. tsc clean. vitest 835/835 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
// Most-recent note preview for the Overview tab (the "do you have anything
// outstanding on this lead?" peek). Returns the latest note's truncated
// content + author/timestamp so the UI can render a one-line teaser.
const [recentNote] = await db
.select({
id: interestNotes.id,
content: interestNotes.content,
authorId: interestNotes.authorId,
createdAt: interestNotes.createdAt,
})
.from(interestNotes)
.where(eq(interestNotes.interestId, id))
.orderBy(desc(interestNotes.createdAt))
.limit(1);
const [{ count: notesCount } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interestNotes)
.where(eq(interestNotes.interestId, id));
// Active reminder count for the interest's bell badge. Counts reminders
// directly linked via interestId - `pending` and `snoozed` only;
feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6, #7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real assignee column on interests, defer until ownership model lands) and #12 (keyboard shortcuts — explicit user call). Interest list (the rep's main triage surface): - Last activity column replaces Created (sortable by dateLastContact). Postgres NULLs-last on DESC means never-contacted leads sort to the bottom — exactly the right triage default. - Comment-icon next to client name when notesCount > 0, with a tooltip showing the count. Cheap, glanceable signal that the lead has correspondence to peek at. - Urgency badges under stage when criteria fire: "Silent Nd" for mid-funnel interests with no contact in 7+ days, "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd" for eoi_signed interests with no deposit after 21 days. Pure derived — no extra fetch, computed from the dates the row already returns. - Bulk select checkbox column with bulk-archive (existing DataTable.bulkActions API; just wired with a confirm-dialog and a Promise.all fan-out). - Mobile FAB (+) for new interest, anchored above the bottom-tab bar with safe-area inset awareness. All four signals mirrored on the mobile InterestCard (comment icon, urgency badges, last-activity footer). Interest detail: - Reminder bell badge in the header showing pending/snoozed reminder count linked to the interest. Surfaced via getInterestById's new `activeReminderCount`. - "Latest note" teaser on the Overview tab — truncated 3-line preview of the most recent threaded note + relative time + "View all" link to the Notes tab. Saves a click for the common "what was discussed last?" peek. - Color-block swatches in InlineStagePicker dropdown (rounded-sm mini-bars in the stage's progressive saturation color, replacing the previous tiny dots). Reads as a visual scan instead of a list. Dashboard: - MyRemindersRail on the right sidebar above the existing AlertRail. Shows pending+snoozed reminders for the current user (overdue first), each with priority pill, relative due time, and click-through to the linked interest/client/berth. Berth detail: - BerthInterestPulse card at the top of the Overview tab, replacing the old "buried in tab" pattern. Shows up to 5 active interests with avatar, stage pill, urgency badges, and last-activity. Mirrors the old Nuxt CRM's beloved "Interested Parties" panel but with the new triage signals. Realtime toasts: - New <RealtimeToasts /> mounted inside SocketProvider in the dashboard layout. Subscribes to interest:stageChanged, document:completed, document:signer:signed, and interest:outcomeSet — fires sonner toasts so reps watching any page learn about pipeline events without refreshing. Service layer: - listInterests: notesCount per row (left join + count + groupBy). - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164 (for the Email/Call/WhatsApp buttons added last commit; phone pieces were missing), notesCount, recentNote, activeReminderCount. - sortColumn switch handles 'dateLastContact' explicitly; default stays 'updatedAt'. tsc clean. vitest 835/835 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
// completed/dismissed don't surface.
const [{ count: activeReminderCount } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(reminders)
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
return {
...interest,
clientName: clientRow?.fullName ?? null,
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
clientPrimaryEmail: emailContact?.value ?? null,
clientPrimaryPhone: phoneContact?.value ?? null,
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
clientHasAddress: !!addressRow,
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
berthId,
berthMooringNumber,
tags: tagRows,
feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6, #7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real assignee column on interests, defer until ownership model lands) and #12 (keyboard shortcuts — explicit user call). Interest list (the rep's main triage surface): - Last activity column replaces Created (sortable by dateLastContact). Postgres NULLs-last on DESC means never-contacted leads sort to the bottom — exactly the right triage default. - Comment-icon next to client name when notesCount > 0, with a tooltip showing the count. Cheap, glanceable signal that the lead has correspondence to peek at. - Urgency badges under stage when criteria fire: "Silent Nd" for mid-funnel interests with no contact in 7+ days, "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd" for eoi_signed interests with no deposit after 21 days. Pure derived — no extra fetch, computed from the dates the row already returns. - Bulk select checkbox column with bulk-archive (existing DataTable.bulkActions API; just wired with a confirm-dialog and a Promise.all fan-out). - Mobile FAB (+) for new interest, anchored above the bottom-tab bar with safe-area inset awareness. All four signals mirrored on the mobile InterestCard (comment icon, urgency badges, last-activity footer). Interest detail: - Reminder bell badge in the header showing pending/snoozed reminder count linked to the interest. Surfaced via getInterestById's new `activeReminderCount`. - "Latest note" teaser on the Overview tab — truncated 3-line preview of the most recent threaded note + relative time + "View all" link to the Notes tab. Saves a click for the common "what was discussed last?" peek. - Color-block swatches in InlineStagePicker dropdown (rounded-sm mini-bars in the stage's progressive saturation color, replacing the previous tiny dots). Reads as a visual scan instead of a list. Dashboard: - MyRemindersRail on the right sidebar above the existing AlertRail. Shows pending+snoozed reminders for the current user (overdue first), each with priority pill, relative due time, and click-through to the linked interest/client/berth. Berth detail: - BerthInterestPulse card at the top of the Overview tab, replacing the old "buried in tab" pattern. Shows up to 5 active interests with avatar, stage pill, urgency badges, and last-activity. Mirrors the old Nuxt CRM's beloved "Interested Parties" panel but with the new triage signals. Realtime toasts: - New <RealtimeToasts /> mounted inside SocketProvider in the dashboard layout. Subscribes to interest:stageChanged, document:completed, document:signer:signed, and interest:outcomeSet — fires sonner toasts so reps watching any page learn about pipeline events without refreshing. Service layer: - listInterests: notesCount per row (left join + count + groupBy). - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164 (for the Email/Call/WhatsApp buttons added last commit; phone pieces were missing), notesCount, recentNote, activeReminderCount. - sortColumn switch handles 'dateLastContact' explicitly; default stays 'updatedAt'. tsc clean. vitest 835/835 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:09:51 +02:00
notesCount,
recentNote: recentNote ?? null,
activeReminderCount,
};
}
// ─── Create ───────────────────────────────────────────────────────────────────
export async function createInterest(portId: string, data: CreateInterestInput, meta: AuditMeta) {
await assertInterestFksInPort(portId, {
clientId: data.clientId,
berthId: data.berthId,
yachtId: data.yachtId,
});
if (data.yachtId) {
await assertYachtBelongsToClient(portId, data.yachtId, data.clientId);
}
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
const { tagIds, berthId: inputBerthId, ...interestData } = data;
// BR-011: auto-promote leadCategory
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
const resolvedLeadCategory = await resolveLeadCategory(
data.clientId,
data.leadCategory,
data.yachtId,
);
const result = await withTransaction(async (tx) => {
const [interest] = await tx
.insert(interests)
.values({
portId,
...interestData,
leadCategory: resolvedLeadCategory,
})
.returning();
if (tagIds && tagIds.length > 0) {
await tx
.insert(interestTags)
.values(tagIds.map((tagId) => ({ interestId: interest!.id, tagId })));
}
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// Plan §3.4: when berthId is provided we materialise it as a junction
// row inside the same transaction so an interest is never created
// without its primary-berth link surviving rollback.
if (inputBerthId) {
await upsertInterestBerthTx(tx, interest!.id, inputBerthId, {
isPrimary: true,
isSpecificInterest: true,
isInEoiBundle: false,
addedBy: meta.userId,
});
}
return interest!;
});
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'interest',
entityId: result.id,
newValue: { clientId: result.clientId, pipelineStage: result.pipelineStage },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:created', {
interestId: result.id,
clientId: result.clientId,
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
berthId: inputBerthId ?? null,
source: result.source ?? '',
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'interest:created', {
interestId: result.id,
clientId: result.clientId,
}),
);
return result;
}
// ─── Update ───────────────────────────────────────────────────────────────────
export async function updateInterest(
id: string,
portId: string,
data: UpdateInterestInput,
meta: AuditMeta,
) {
const existing = await db.query.interests.findFirst({
where: eq(interests.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Interest');
}
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// berthId no longer lives on the interests row - resolve current primary
// via the junction so we know whether the caller is asking for a change.
const currentPrimary = await getPrimaryBerth(id);
const currentBerthId = currentPrimary?.berthId ?? null;
await assertInterestFksInPort(portId, {
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
berthId: data.berthId && data.berthId !== currentBerthId ? data.berthId : null,
yachtId: data.yachtId && data.yachtId !== existing.yachtId ? data.yachtId : null,
});
if (data.yachtId && data.yachtId !== existing.yachtId) {
await assertYachtBelongsToClient(portId, data.yachtId, existing.clientId);
}
// BR-011: auto-promote leadCategory if provided
let resolvedLeadCategory = data.leadCategory;
if ('leadCategory' in data) {
resolvedLeadCategory = (await resolveLeadCategory(
existing.clientId,
data.leadCategory,
refactor(clients): drop deprecated yacht/company/proxy columns PR 13: now that all reads are migrated to the dedicated yacht / company / membership entities, drop the columns that mirrored them on `clients`: companyName, isProxy, proxyType, actualOwnerName, relationshipNotes, yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M}, berthSizeDesired. Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to apply. Caller cleanup (zero behavioral change to remaining flows): - Drops the legacy `generateEoi` flow entirely (route, service function, pdfme template, validator schema). The dual-path generate-and-sign service from PR 11 has fully replaced it; the route was no longer wired to the UI. - `clients.service`: company-name search column / WHERE / audit value removed; search now ranks by full name only. - `interests.service`: `resolveLeadCategory` reads dimensions from `yachts` via `interest.yachtId` instead of the dropped `client.yachtLength{Ft,M}`. - `record-export`: client-summary now lists yachts via owner-side lookup (direct + active company memberships); interest-summary fetches yacht via `interest.yachtId`. Both PDF templates updated to read yacht details from the new entity. - `client-detail-header`, `client-picker`, `command-search`, `search-result-item`, `use-search` hook, `types/domain.ts`, `search.service` — drop the companyName badge / sub-label / typed field everywhere it was rendered or fetched. - `ai.ts` worker: drop the company / yacht context lines from the prompt (will be re-added later sourced from the new entities). - `validators/interests.ts`: remove the deprecated public-form flat yacht/company fields. The route already ignores them. - `factories.ts`: drop the `isProxy: false` default. Tests: 652/652 green; type-check clean. The `security-sensitive-data` tests use `companyName` / `isProxy` as arbitrary record keys for a generic util — left unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
data.yachtId ?? existing.yachtId,
)) as typeof data.leadCategory;
}
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// Strip berthId out of the row write - the column was removed by the
// junction-migration. We keep the value for diff/audit purposes and
// dispatch the junction write separately.
const { berthId: incomingBerthId, ...rowData } = data;
const updateData = { ...rowData, leadCategory: resolvedLeadCategory };
const { diff } = diffEntity(
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
{ ...(existing as Record<string, unknown>), berthId: currentBerthId },
{ ...(updateData as Record<string, unknown>), berthId: incomingBerthId ?? currentBerthId },
);
const [updated] = await db
.update(interests)
.set({ ...updateData, updatedAt: new Date() })
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
.returning();
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
// Apply primary-berth change through the junction so the unique
// partial index is respected and the previous primary is demoted.
if ('berthId' in data && incomingBerthId !== currentBerthId) {
if (incomingBerthId) {
await upsertInterestBerth(id, incomingBerthId, {
isPrimary: true,
isSpecificInterest: true,
addedBy: meta.userId,
});
} else if (currentBerthId) {
await removeInterestBerth(id, currentBerthId, portId);
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
}
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'interest',
entityId: id,
oldValue: diff as Record<string, unknown>,
newValue: updateData as Record<string, unknown>,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:updated', {
interestId: id,
changedFields: Object.keys(diff),
});
return updated!;
}
// ─── Change Stage ─────────────────────────────────────────────────────────────
export async function changeInterestStage(
id: string,
portId: string,
data: ChangeStageInput,
meta: AuditMeta,
) {
const existing = await db.query.interests.findFirst({
where: eq(interests.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Interest');
}
// Plan: yachtId required to leave stage=open
if (existing.pipelineStage === 'open' && data.pipelineStage !== 'open' && !existing.yachtId) {
throw new ValidationError('yachtId is required before leaving stage=open');
}
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
// Block egregious skips. The transition table allows reasonable forward
// jumps (e.g. open → eoi_sent) while rejecting things like completed → open
// or open → contract_signed. Same-stage no-ops are allowed.
feat(interests): manual stage override + Residential Partner system role Manual stage override Sales reps need to skip canTransitionStage rules when the data was entered out of order — e.g. recording a contract_signed deal whose earlier stages were never tracked in the system. - New permission flag interests.override_stage in RolePermissions. Plumbed through the schema TS type, the role-editor UI, the seed file's pre-built roles (super_admin/director/sales_manager get it, sales_agent + viewer don't), and the test factories. - changeStageSchema gains an optional `override` boolean and the service checks it before evaluating canTransitionStage. When override=true the reason field becomes required (min 5 chars) and is recorded in the audit log. - The route handler gates `override` on the new permission so a sales_agent without it can't pass override=true and bypass. - InterestStagePicker auto-detects when the requested transition is blocked by the table and switches into "override mode" — shows an amber warning, requires the reason, button label flips to "Override stage". When the operator lacks the permission, the warning is red and the button is disabled. Residential Partner role Per the smart-archive scoping conversation: external partners who handle residential inquiries shouldn't see marina clients, yachts, berths, or financials. The two residential_* permission groups already exist; this commit just seeds a pre-built system role ("residential_partner") with those flags + minimal own-reminders, so admins can invite a partner today via /admin/users without manually building the permission set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:32:57 +02:00
// Override (sales-rep manual fix) bypasses the table — the route handler
// gates this on the `interests.override_stage` permission and requires
// a reason, recorded in the audit log below.
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
throw new ValidationError(
feat(interests): manual stage override + Residential Partner system role Manual stage override Sales reps need to skip canTransitionStage rules when the data was entered out of order — e.g. recording a contract_signed deal whose earlier stages were never tracked in the system. - New permission flag interests.override_stage in RolePermissions. Plumbed through the schema TS type, the role-editor UI, the seed file's pre-built roles (super_admin/director/sales_manager get it, sales_agent + viewer don't), and the test factories. - changeStageSchema gains an optional `override` boolean and the service checks it before evaluating canTransitionStage. When override=true the reason field becomes required (min 5 chars) and is recorded in the audit log. - The route handler gates `override` on the new permission so a sales_agent without it can't pass override=true and bypass. - InterestStagePicker auto-detects when the requested transition is blocked by the table and switches into "override mode" — shows an amber warning, requires the reason, button label flips to "Override stage". When the operator lacks the permission, the warning is red and the button is disabled. Residential Partner role Per the smart-archive scoping conversation: external partners who handle residential inquiries shouldn't see marina clients, yachts, berths, or financials. The two residential_* permission groups already exist; this commit just seeds a pre-built system role ("residential_partner") with those flags + minimal own-reminders, so admins can invite a partner today via /admin/users without manually building the permission set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:32:57 +02:00
`Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}". Use the override option if you need to skip stages — requires a reason.`,
);
}
if (data.override && (!data.reason || data.reason.trim().length < 5)) {
throw new ValidationError(
'Override requires a reason (min 5 chars) explaining the manual stage change.',
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
);
}
const oldStage = existing.pipelineStage;
const [updated] = await db
.update(interests)
.set({ pipelineStage: data.pipelineStage, updatedAt: new Date() })
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
.returning();
// BR-133: Auto-populate milestones based on stage
const milestoneUpdates: Record<string, unknown> = {};
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = new Date();
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = new Date();
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = new Date();
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = new Date();
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = new Date();
if (Object.keys(milestoneUpdates).length > 0) {
await db
.update(interests)
.set({ ...milestoneUpdates, updatedAt: new Date() })
.where(eq(interests.id, id));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'interest',
entityId: id,
oldValue: { pipelineStage: oldStage },
newValue: { pipelineStage: data.pipelineStage, reason: data.reason },
metadata: { type: 'stage_change', reason: data.reason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:stageChanged', {
interestId: id,
oldStage: oldStage ?? '',
newStage: data.pipelineStage,
clientName: '',
berthNumber: '',
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'interest:stageChanged', {
interestId: id,
oldStage: oldStage ?? null,
newStage: data.pipelineStage,
}),
);
// Fire-and-forget notification to the acting user
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
createNotification({
portId,
userId: meta.userId,
type: 'interest_stage_changed',
title: `Interest moved to ${data.pipelineStage}`,
description: `Interest ${id} stage changed from ${oldStage ?? 'unknown'} to ${data.pipelineStage}`,
link: `/interests/${id}`,
entityType: 'interest',
entityId: id,
dedupeKey: `interest:${id}:stage:${data.pipelineStage}`,
cooldownMs: 300_000,
}),
);
return updated!;
}
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
// ─── Advance Stage If Behind ─────────────────────────────────────────────────
//
// Moves an interest forward to `target` if (and only if) it is currently behind
// it in the pipeline order. Used by lifecycle events (EOI sent, EOI signed,
// deposit recorded, contract signed) so the user-visible stage tracks reality
// without overwriting a more advanced state - e.g. a late-arriving signed-EOI
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
// webhook on an interest that has already moved on to `contract_sent` is a
// no-op rather than a regression.
//
// Returns true when the stage was changed.
export async function advanceStageIfBehind(
interestId: string,
portId: string,
target: PipelineStage,
meta: AuditMeta,
reason?: string,
): Promise<boolean> {
const existing = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!existing) return false;
const currentIdx = PIPELINE_STAGES.indexOf(existing.pipelineStage as PipelineStage);
const targetIdx = PIPELINE_STAGES.indexOf(target);
if (currentIdx === -1 || targetIdx === -1 || currentIdx >= targetIdx) {
return false;
}
// yachtId gate: changeInterestStage requires a yacht before leaving `open`.
// EOI events imply a yacht is in the picture, but if the data is missing we
// bail rather than throw - the EOI itself shouldn't fail because of this.
refactor(sales): consolidate pipeline stages + wire EOI auto-advance The 8→9 stage refresh from earlier today only updated constants.ts and the DB — 20 component/service files still hardcoded the old enum, leaving labels blank, filter dropdowns wrong, kanban columns mismatched, and the analytics funnel silently dropping new-stage rows. The platform also never advanced pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus but left the user-visible stage stuck. This commit closes both gaps: 1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS, STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus stageLabel / stageBadgeClass / stageDotClass / safeStage / canTransitionStage helpers. components/clients/pipeline-constants.ts becomes a re-export shim so existing imports keep working. 2. 18 stale-enum surfaces migrated — interest list (table, card, filters, form, stage picker), pipeline board, client card, berth interests tab, portal client interests page, dashboard pipeline / funnel / revenue- forecast charts, settings pipeline_weights default, dashboard.service weights, analytics.service funnel stages, alert-rules stale-interest filter, interest-scoring stage rank. 3. Documents tab wired into interest detail — replaced the placeholder in interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the EOI launcher is back where salespeople work. 4. Auto-advance — new advanceStageIfBehind() in interests.service.ts (forward-only, no-op if interest is already past the target). Called from documents.service.ts on send (→ eoi_sent), Documenso completed webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed). 5. Transition guard — canTransitionStage() blocks egregious skips (e.g. completed → open, open → contract_signed). Enforced in changeInterestStage before the DB write. Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832, ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
if (existing.pipelineStage === 'open' && !existing.yachtId) {
return false;
}
await changeInterestStage(interestId, portId, { pipelineStage: target, reason }, meta);
return true;
}
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
// ─── Set Outcome (Won / Lost) ────────────────────────────────────────────────
//
// Records a terminal outcome for the interest and moves the pipelineStage to
// `completed` so the funnel/kanban reflect the final state. The outcome
// distinguishes won deals (they made it through) from lost variants - funnel
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
// math and reports key off the `outcome` column to compute true conversion.
//
// Both the stage advance and the outcome write happen in one transaction so
// the timeline doesn't end up showing one without the other.
export async function setInterestOutcome(
id: string,
portId: string,
data: SetOutcomeInput,
meta: AuditMeta,
) {
const existing = await db.query.interests.findFirst({
where: and(eq(interests.id, id), eq(interests.portId, portId)),
});
if (!existing) throw new NotFoundError('Interest');
const oldOutcome = existing.outcome;
const oldStage = existing.pipelineStage;
const now = new Date();
await db
.update(interests)
.set({
outcome: data.outcome,
outcomeReason: data.reason ?? null,
outcomeAt: now,
pipelineStage: 'completed',
updatedAt: now,
})
.where(and(eq(interests.id, id), eq(interests.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'interest',
entityId: id,
oldValue: { outcome: oldOutcome, pipelineStage: oldStage },
newValue: { outcome: data.outcome, pipelineStage: 'completed', reason: data.reason },
metadata: { type: 'outcome_set' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:outcomeSet', {
interestId: id,
outcome: data.outcome,
oldStage,
});
return { ok: true as const };
}
// Clears a terminal outcome and reopens the interest. Used when an outcome
// was set in error or a "lost" deal comes back to life.
export async function clearInterestOutcome(
id: string,
portId: string,
data: ClearOutcomeInput,
meta: AuditMeta,
) {
const existing = await db.query.interests.findFirst({
where: and(eq(interests.id, id), eq(interests.portId, portId)),
});
if (!existing) throw new NotFoundError('Interest');
if (!existing.outcome) {
throw new ValidationError('Interest has no outcome to clear');
}
const reopenStage = data.reopenStage ?? 'in_communication';
const now = new Date();
await db
.update(interests)
.set({
outcome: null,
outcomeReason: null,
outcomeAt: null,
pipelineStage: reopenStage,
updatedAt: now,
})
.where(and(eq(interests.id, id), eq(interests.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'interest',
entityId: id,
oldValue: { outcome: existing.outcome, pipelineStage: existing.pipelineStage },
newValue: { outcome: null, pipelineStage: reopenStage },
metadata: { type: 'outcome_cleared' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:outcomeCleared', { interestId: id });
return { ok: true as const };
}
// ─── Archive / Restore ────────────────────────────────────────────────────────
export async function archiveInterest(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.interests.findFirst({
where: eq(interests.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Interest');
}
// BR-014: Block archive if pending EOI/contract
if (existing.eoiStatus === 'waiting_for_signatures' || existing.contractStatus === 'pending') {
throw new ConflictError(
'Cannot archive interest with pending documents. Cancel documents first.',
);
}
await softDelete(interests, interests.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'interest',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:archived', { interestId: id });
}
export async function restoreInterest(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.interests.findFirst({
where: eq(interests.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Interest');
}
await restore(interests, interests.id, id);
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'interest',
entityId: id,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:updated', { interestId: id, changedFields: [] });
}
// ─── Set Tags ─────────────────────────────────────────────────────────────────
export async function setInterestTags(
id: string,
portId: string,
tagIds: string[],
meta: AuditMeta,
) {
const existing = await db.query.interests.findFirst({
where: eq(interests.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Interest');
}
const result = await setEntityTags({
joinTable: interestTags,
entityColumn: interestTags.interestId,
tagColumn: interestTags.tagId,
entityId: id,
portId,
tagIds,
meta,
entityType: 'interest',
});
return { interestId: result.entityId, tagIds: result.tagIds };
}
// ─── Link / Unlink Berth ──────────────────────────────────────────────────────
export async function linkBerth(id: string, portId: string, berthId: string, meta: AuditMeta) {
const existing = await db.query.interests.findFirst({
where: eq(interests.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Interest');
}
await assertInterestFksInPort(portId, { berthId });
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
const previousPrimary = await getPrimaryBerth(id);
const oldBerthId = previousPrimary?.berthId ?? null;
await upsertInterestBerth(id, berthId, {
isPrimary: true,
isSpecificInterest: true,
addedBy: meta.userId,
});
// Touch updatedAt so list/sort surfaces still reflect the change.
const [updated] = await db
.update(interests)
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
.set({ updatedAt: new Date() })
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'interest',
entityId: id,
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
oldValue: { berthId: oldBerthId },
newValue: { berthId },
metadata: { type: 'berth_linked' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:berthLinked', { interestId: id, berthId });
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'interest:berthLinked', { interestId: id, berthId }),
);
return updated!;
}
export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) {
const existing = await db.query.interests.findFirst({
where: eq(interests.id, id),
});
if (!existing || existing.portId !== portId) {
throw new NotFoundError('Interest');
}
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
const previousPrimary = await getPrimaryBerth(id);
const oldBerthId = previousPrimary?.berthId ?? null;
if (oldBerthId) {
await removeInterestBerth(id, oldBerthId, portId);
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
}
const [updated] = await db
.update(interests)
refactor(interests): migrate callers to interest_berths junction + drop berth_id Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of the legacy `interests.berth_id` column now reads / writes through the `interest_berths` junction via the helper service introduced in Phase 2a; the column itself is dropped in a final migration. Service-layer changes - interests.service: filter `?berthId=X` becomes EXISTS-against-junction; list enrichment uses `getPrimaryBerthsForInterests`; create/update/ linkBerth/unlinkBerth all dispatch through the junction helpers, with createInterest's row insert + junction write sharing a single transaction. - clients / dashboard / report-generators / search: leftJoin chains pivot through `interest_berths` filtered by `is_primary=true`. - eoi-context / document-templates / berth-rules-engine / portal / record-export / queue worker: read primary via `getPrimaryBerth(...)`. - interest-scoring: berthLinked is now derived from any junction row count. - dedup/migration-apply + public interest route: write a primary junction row alongside the interest insert when a berth is provided. API contract preserved: list/detail responses still emit `berthId` and `berthMooringNumber`, derived from the primary junction row, so frontend consumers (interest-form, interest-detail-header) need no changes. Schema + migration - Drop `interestsRelations.berth` and `idx_interests_berth`. - Replace `berthsRelations.interests` with `interestBerths`. - Migration 0029_puzzling_romulus drops `interests.berth_id` + the index. - Tests that previously inserted `interests.berthId` now seed a primary junction row alongside the interest. Verified: vitest 995 passing (1 unrelated pre-existing flake in maintenance-cleanup.test.ts), tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00
.set({ updatedAt: new Date() })
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'interest',
entityId: id,
oldValue: { berthId: oldBerthId },
newValue: { berthId: null },
metadata: { type: 'berth_unlinked' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'interest:berthUnlinked', {
interestId: id,
berthId: oldBerthId ?? '',
});
return updated!;
}
// ─── Stage Counts (for board) ────────────────────────────────────────────────
export async function getInterestStageCounts(portId: string) {
const rows = await db
.select({ stage: interests.pipelineStage, count: sql<number>`count(*)::int` })
.from(interests)
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
.groupBy(interests.pipelineStage);
return Object.fromEntries(rows.map((r) => [r.stage, r.count]));
}