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 ) {
fix(audit-tier-4): tenant-isolation defense-in-depth
Closes the audit's HIGH §10 + MED §§17–22 isolation footguns. None of
these are user-impactful TODAY — every site is preceded by a port-
scoped read or pre-validated by ctx.portId — but each is a future-
refactor accident waiting to happen, so the SQL itself now pins the
tenant boundary:
* mergeClients gains a callerPortId option; the route caller passes
ctx.portId. removeInterestBerth now requires portId and verifies
both the interest and the berth share it before deleting the
junction row. All three callers updated.
* Six service mutations now scope the WHERE to (id, portId):
form-templates update + delete, invoices.detectOverdue per-row
update, notifications.markRead, clients.deleteRelationship.
company-memberships uses an inArray sub-select against port
companies (no port_id column on the table itself), covering
updateMembership / endMembership / setPrimary.
* Port-scoped file lookups in portal.getDocumentDownloadUrl,
reports.getDownloadUrl (file presign), berth-reservations.activate
(contractFileId attach guard), and residential.getResidentialInterestById
(residentialClient join).
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §10 + MED §§17–22
(auditor-B3 Issues 1–5,7).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:48:13 +02:00
await removeInterestBerth ( id , currentBerthId , portId ) ;
2026-05-05 02:41:52 +02:00
}
}
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.
feat(interests): manual stage override + Residential Partner system role
Manual stage override
Sales reps need to skip canTransitionStage rules when the data was
entered out of order — e.g. recording a contract_signed deal whose
earlier stages were never tracked in the system.
- New permission flag interests.override_stage in RolePermissions.
Plumbed through the schema TS type, the role-editor UI, the seed
file's pre-built roles (super_admin/director/sales_manager get it,
sales_agent + viewer don't), and the test factories.
- changeStageSchema gains an optional `override` boolean and the
service checks it before evaluating canTransitionStage. When
override=true the reason field becomes required (min 5 chars) and
is recorded in the audit log.
- The route handler gates `override` on the new permission so a
sales_agent without it can't pass override=true and bypass.
- InterestStagePicker auto-detects when the requested transition is
blocked by the table and switches into "override mode" — shows an
amber warning, requires the reason, button label flips to
"Override stage". When the operator lacks the permission, the
warning is red and the button is disabled.
Residential Partner role
Per the smart-archive scoping conversation: external partners who
handle residential inquiries shouldn't see marina clients, yachts,
berths, or financials. The two residential_* permission groups
already exist; this commit just seeds a pre-built system role
("residential_partner") with those flags + minimal own-reminders, so
admins can invite a partner today via /admin/users without manually
building the permission set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:32:57 +02:00
// Override (sales-rep manual fix) bypasses the table — the route handler
// gates this on the `interests.override_stage` permission and requires
// a reason, recorded in the audit log below.
if ( ! data . override && ! canTransitionStage ( existing . pipelineStage , data . pipelineStage ) ) {
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
throw new ValidationError (
feat(interests): manual stage override + Residential Partner system role
Manual stage override
Sales reps need to skip canTransitionStage rules when the data was
entered out of order — e.g. recording a contract_signed deal whose
earlier stages were never tracked in the system.
- New permission flag interests.override_stage in RolePermissions.
Plumbed through the schema TS type, the role-editor UI, the seed
file's pre-built roles (super_admin/director/sales_manager get it,
sales_agent + viewer don't), and the test factories.
- changeStageSchema gains an optional `override` boolean and the
service checks it before evaluating canTransitionStage. When
override=true the reason field becomes required (min 5 chars) and
is recorded in the audit log.
- The route handler gates `override` on the new permission so a
sales_agent without it can't pass override=true and bypass.
- InterestStagePicker auto-detects when the requested transition is
blocked by the table and switches into "override mode" — shows an
amber warning, requires the reason, button label flips to
"Override stage". When the operator lacks the permission, the
warning is red and the button is disabled.
Residential Partner role
Per the smart-archive scoping conversation: external partners who
handle residential inquiries shouldn't see marina clients, yachts,
berths, or financials. The two residential_* permission groups
already exist; this commit just seeds a pre-built system role
("residential_partner") with those flags + minimal own-reminders, so
admins can invite a partner today via /admin/users without manually
building the permission set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:32:57 +02:00
` Cannot move interest from " ${ existing . pipelineStage } " directly to " ${ data . pipelineStage } ". Use the override option if you need to skip stages — requires a reason. ` ,
) ;
}
if ( data . override && ( ! data . reason || data . reason . trim ( ) . length < 5 ) ) {
throw new ValidationError (
'Override requires a reason (min 5 chars) explaining the manual stage change.' ,
refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.
This commit closes both gaps:
1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
stageLabel / stageBadgeClass / stageDotClass / safeStage /
canTransitionStage helpers. components/clients/pipeline-constants.ts
becomes a re-export shim so existing imports keep working.
2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
form, stage picker), pipeline board, client card, berth interests tab,
portal client interests page, dashboard pipeline / funnel / revenue-
forecast charts, settings pipeline_weights default, dashboard.service
weights, analytics.service funnel stages, alert-rules stale-interest
filter, interest-scoring stage rank.
3. Documents tab wired into interest detail — replaced the placeholder in
interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
EOI launcher is back where salespeople work.
4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
(forward-only, no-op if interest is already past the target). Called
from documents.service.ts on send (→ eoi_sent), Documenso completed
webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).
5. Transition guard — canTransitionStage() blocks egregious skips
(e.g. completed → open, open → contract_signed). Enforced in
changeInterestStage before the DB write.
Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00
) ;
}
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 ) {
fix(audit-tier-4): tenant-isolation defense-in-depth
Closes the audit's HIGH §10 + MED §§17–22 isolation footguns. None of
these are user-impactful TODAY — every site is preceded by a port-
scoped read or pre-validated by ctx.portId — but each is a future-
refactor accident waiting to happen, so the SQL itself now pins the
tenant boundary:
* mergeClients gains a callerPortId option; the route caller passes
ctx.portId. removeInterestBerth now requires portId and verifies
both the interest and the berth share it before deleting the
junction row. All three callers updated.
* Six service mutations now scope the WHERE to (id, portId):
form-templates update + delete, invoices.detectOverdue per-row
update, notifications.markRead, clients.deleteRelationship.
company-memberships uses an inArray sub-select against port
companies (no port_id column on the table itself), covering
updateMembership / endMembership / setPrimary.
* Port-scoped file lookups in portal.getDocumentDownloadUrl,
reports.getDownloadUrl (file presign), berth-reservations.activate
(contractFileId attach guard), and residential.getResidentialInterestById
(residentialClient join).
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md HIGH §10 + MED §§17–22
(auditor-B3 Issues 1–5,7).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:48:13 +02:00
await removeInterestBerth ( id , oldBerthId , portId ) ;
2026-05-05 02:41:52 +02:00
}
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
}