2026-05-05 02:41:52 +02:00
|
|
|
import { and, desc, eq, exists, inArray, isNull, sql } from 'drizzle-orm';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
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';
|
2026-05-02 03:11:14 +02:00
|
|
|
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { berths } from '@/lib/db/schema/berths';
|
2026-04-24 15:34:44 +02:00
|
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
|
|
|
import { companyMemberships } from '@/lib/db/schema/companies';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { tags } from '@/lib/db/schema/system';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
2026-04-24 15:34:44 +02:00
|
|
|
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { emitToRoom } from '@/lib/socket/server';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
2026-05-05 02:41:52 +02:00
|
|
|
import {
|
|
|
|
|
getPrimaryBerth,
|
|
|
|
|
getPrimaryBerthsForInterests,
|
|
|
|
|
removeInterestBerth,
|
|
|
|
|
upsertInterestBerth,
|
|
|
|
|
upsertInterestBerthTx,
|
|
|
|
|
} from '@/lib/services/interest-berths.service';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import type {
|
|
|
|
|
CreateInterestInput,
|
|
|
|
|
UpdateInterestInput,
|
|
|
|
|
ChangeStageInput,
|
|
|
|
|
ListInterestsInput,
|
2026-05-02 00:01:33 +02:00
|
|
|
SetOutcomeInput,
|
|
|
|
|
ClearOutcomeInput,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
} from '@/lib/validators/interests';
|
|
|
|
|
|
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-29 04:14:09 +02:00
|
|
|
// ─── Port-scope FK validator ─────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-04 22:57:01 +02:00
|
|
|
// 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
|
2026-04-29 04:14:09 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
// ─── 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');
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── 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,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
): 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';
|
|
|
|
|
}
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return leadCategory ?? undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listInterests(portId: string, query: ListInterestsInput) {
|
|
|
|
|
const {
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
sort,
|
|
|
|
|
order,
|
|
|
|
|
search,
|
|
|
|
|
includeArchived,
|
|
|
|
|
clientId,
|
2026-04-24 15:34:44 +02:00
|
|
|
yachtId,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
berthId,
|
|
|
|
|
pipelineStage,
|
|
|
|
|
leadCategory,
|
|
|
|
|
eoiStatus,
|
|
|
|
|
tagIds,
|
|
|
|
|
} = query;
|
|
|
|
|
|
|
|
|
|
const filters = [];
|
|
|
|
|
|
|
|
|
|
if (clientId) {
|
|
|
|
|
filters.push(eq(interests.clientId, clientId));
|
|
|
|
|
}
|
2026-04-24 15:34:44 +02:00
|
|
|
if (yachtId) {
|
|
|
|
|
filters.push(eq(interests.yachtId, yachtId));
|
|
|
|
|
}
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
if (berthId) {
|
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)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
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) {
|
2026-04-24 15:34:44 +02:00
|
|
|
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;
|
2026-04-24 15:34:44 +02:00
|
|
|
default:
|
|
|
|
|
return interests.updatedAt;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
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);
|
2026-04-24 15:34:44 +02:00
|
|
|
const clientIds = [
|
|
|
|
|
...new Set((result.data as Array<{ clientId: string }>).map((i) => i.clientId)),
|
|
|
|
|
];
|
2026-05-05 02:18:13 +02:00
|
|
|
const yachtIds = [
|
|
|
|
|
...new Set(
|
|
|
|
|
(result.data as Array<{ yachtId: string | null }>)
|
|
|
|
|
.map((i) => i.yachtId)
|
|
|
|
|
.filter(Boolean) as string[],
|
|
|
|
|
),
|
|
|
|
|
];
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
let clientsMap: Record<string, string> = {};
|
2026-05-05 02:18:13 +02:00
|
|
|
let yachtsMap: Record<string, string> = {};
|
2026-03-26 12:06:18 +01:00
|
|
|
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> = {};
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
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]));
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-05-05 02:18:13 +02:00
|
|
|
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]));
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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;
|
|
|
|
|
}
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
2026-05-02 03:33:13 +02:00
|
|
|
// 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.
|
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);
|
|
|
|
|
|
2026-05-02 03:33:13 +02:00
|
|
|
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);
|
|
|
|
|
|
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);
|
|
|
|
|
|
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;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
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
|
2026-05-04 22:57:01 +02:00
|
|
|
// 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'])));
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
return {
|
|
|
|
|
...interest,
|
|
|
|
|
clientName: clientRow?.fullName ?? null,
|
2026-05-02 03:11:14 +02:00
|
|
|
clientPrimaryEmail: emailContact?.value ?? null,
|
2026-05-02 03:33:13 +02:00
|
|
|
clientPrimaryPhone: phoneContact?.value ?? null,
|
|
|
|
|
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
|
2026-05-02 03:11:14 +02:00
|
|
|
clientHasAddress: !!addressRow,
|
2026-05-05 02:41:52 +02:00
|
|
|
berthId,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
export async function createInterest(portId: string, data: CreateInterestInput, meta: AuditMeta) {
|
2026-04-29 04:14:09 +02:00
|
|
|
await assertInterestFksInPort(portId, {
|
|
|
|
|
clientId: data.clientId,
|
|
|
|
|
berthId: data.berthId,
|
|
|
|
|
yachtId: data.yachtId,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
if (data.yachtId) {
|
|
|
|
|
await assertYachtBelongsToClient(portId, data.yachtId, data.clientId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 02:41:52 +02:00
|
|
|
const { tagIds, berthId: inputBerthId, ...interestData } = data;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const result = await withTransaction(async (tx) => {
|
|
|
|
|
const [interest] = await tx
|
|
|
|
|
.insert(interests)
|
|
|
|
|
.values({
|
|
|
|
|
portId,
|
|
|
|
|
...interestData,
|
|
|
|
|
leadCategory: resolvedLeadCategory,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (tagIds && tagIds.length > 0) {
|
2026-04-24 15:34:44 +02:00
|
|
|
await tx
|
|
|
|
|
.insert(interestTags)
|
|
|
|
|
.values(tagIds.map((tagId) => ({ interestId: interest!.id, tagId })));
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
emitToRoom(`port:${portId}`, 'interest:created', {
|
|
|
|
|
interestId: result.id,
|
|
|
|
|
clientId: result.clientId,
|
2026-05-05 02:41:52 +02:00
|
|
|
berthId: inputBerthId ?? null,
|
2026-04-24 15:34:44 +02:00
|
|
|
source: result.source ?? '',
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
2026-04-24 15:34:44 +02:00
|
|
|
dispatchWebhookEvent(portId, 'interest:created', {
|
|
|
|
|
interestId: result.id,
|
|
|
|
|
clientId: result.clientId,
|
|
|
|
|
}),
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-04-29 04:14:09 +02:00
|
|
|
await assertInterestFksInPort(portId, {
|
2026-05-05 02:41:52 +02:00
|
|
|
berthId: data.berthId && data.berthId !== currentBerthId ? data.berthId : null,
|
2026-04-29 04:14:09 +02:00
|
|
|
yachtId: data.yachtId && data.yachtId !== existing.yachtId ? data.yachtId : null,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
if (data.yachtId && data.yachtId !== existing.yachtId) {
|
|
|
|
|
await assertYachtBelongsToClient(portId, data.yachtId, existing.clientId);
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// BR-011: auto-promote leadCategory if provided
|
|
|
|
|
let resolvedLeadCategory = data.leadCategory;
|
|
|
|
|
if ('leadCategory' in data) {
|
2026-04-24 15:34:44 +02:00
|
|
|
resolvedLeadCategory = (await resolveLeadCategory(
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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,
|
2026-04-24 15:34:44 +02:00
|
|
|
)) as typeof data.leadCategory;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
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 };
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const { diff } = diffEntity(
|
2026-05-05 02:41:52 +02:00
|
|
|
{ ...(existing as Record<string, unknown>), berthId: currentBerthId },
|
|
|
|
|
{ ...(updateData as Record<string, unknown>), berthId: incomingBerthId ?? currentBerthId },
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(interests)
|
|
|
|
|
.set({ ...updateData, updatedAt: new Date() })
|
|
|
|
|
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01: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,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
emitToRoom(`port:${portId}`, 'interest:updated', {
|
|
|
|
|
interestId: id,
|
|
|
|
|
changedFields: Object.keys(diff),
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
// 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.
|
|
|
|
|
if (!canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
|
|
|
|
throw new ValidationError(
|
|
|
|
|
`Cannot move interest from "${existing.pipelineStage}" directly to "${data.pipelineStage}".`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01: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();
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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();
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
if (Object.keys(milestoneUpdates).length > 0) {
|
2026-04-24 15:34:44 +02:00
|
|
|
await db
|
|
|
|
|
.update(interests)
|
|
|
|
|
.set({ ...milestoneUpdates, updatedAt: new Date() })
|
|
|
|
|
.where(eq(interests.id, id));
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-04 22:57:01 +02:00
|
|
|
// 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
|
2026-05-04 22:57:01 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-05-04 22:57:01 +02:00
|
|
|
// distinguishes won deals (they made it through) from lost variants - funnel
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
// ─── 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') {
|
2026-04-24 15:34:44 +02:00
|
|
|
throw new ConflictError(
|
|
|
|
|
'Cannot archive interest with pending documents. Cancel documents first.',
|
|
|
|
|
);
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
const result = await setEntityTags({
|
|
|
|
|
joinTable: interestTags,
|
|
|
|
|
entityColumn: interestTags.interestId,
|
|
|
|
|
tagColumn: interestTags.tagId,
|
|
|
|
|
entityId: id,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
portId,
|
2026-04-29 01:58:42 +02:00
|
|
|
tagIds,
|
|
|
|
|
meta,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
entityType: 'interest',
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
return { interestId: result.entityId, tagIds: result.tagIds };
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Link / Unlink Berth ──────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
export async function linkBerth(id: string, portId: string, berthId: string, meta: AuditMeta) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const existing = await db.query.interests.findFirst({
|
|
|
|
|
where: eq(interests.id, id),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
|
|
|
throw new NotFoundError('Interest');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 04:14:09 +02:00
|
|
|
await assertInterestFksInPort(portId, { berthId });
|
|
|
|
|
|
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.
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const [updated] = await db
|
|
|
|
|
.update(interests)
|
2026-05-05 02:41:52 +02:00
|
|
|
.set({ updatedAt: new Date() })
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'interest',
|
|
|
|
|
entityId: id,
|
2026-05-05 02:41:52 +02:00
|
|
|
oldValue: { berthId: oldBerthId },
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 02:41:52 +02:00
|
|
|
const previousPrimary = await getPrimaryBerth(id);
|
|
|
|
|
const oldBerthId = previousPrimary?.berthId ?? null;
|
|
|
|
|
|
|
|
|
|
if (oldBerthId) {
|
|
|
|
|
await removeInterestBerth(id, oldBerthId);
|
|
|
|
|
}
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(interests)
|
2026-05-05 02:41:52 +02:00
|
|
|
.set({ updatedAt: new Date() })
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
.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,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 15:34:44 +02:00
|
|
|
emitToRoom(`port:${portId}`, 'interest:berthUnlinked', {
|
|
|
|
|
interestId: id,
|
|
|
|
|
berthId: oldBerthId ?? '',
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
return updated!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Stage Counts (for board) ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getInterestStageCounts(portId: string) {
|
2026-04-24 15:34:44 +02:00
|
|
|
const rows = await db
|
|
|
|
|
.select({ stage: interests.pipelineStage, count: sql<number>`count(*)::int` })
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
.from(interests)
|
|
|
|
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
|
|
|
|
.groupBy(interests.pipelineStage);
|
2026-04-24 15:34:44 +02:00
|
|
|
return Object.fromEntries(rows.map((r) => [r.stage, r.count]));
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|