feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul

Major interest workflow expansion driven by the rapid-fire UX session.

EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.

Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.

Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.

Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).

Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).

Berth interest list overhaul:
  - Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
  - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
  - Per-letter row tinting via colored left-border accent + dot in cell
  - Documents tab merged Files (single attachments section)

Topbar improvements:
  - Always-visible back arrow on detail pages (path depth > 2)
  - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
    push their entity hierarchy (Clients › Mary Smith › Interest › B17)
  - Tighter spacing, softer separators, 160px crumb truncation

DataTable upgrades:
  - Page-size selector with All option (validator cap raised to 1000)
  - getRowClassName slot for per-row styling (used by berth tinting)
  - Fixed Radix SelectItem crash on empty-string values via __any__
    sentinel (was crashing every list page that opened a select filter)

Interest list:
  - Configurable columns picker
  - Stage cell clickable into detail
  - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
  - Save view moved into ColumnPicker menu; Views button hidden when
    no views are saved
  - Pipeline kanban board endpoint at /api/v1/interests/board with
    minimal projection, 5000-row cap + truncated banner, filter
    pass-through

Mobile chrome + sidebar collapse removed (always-expanded design choice).

User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:59:28 +02:00
parent 267c2b6d1f
commit 3e4d9d6310
87 changed files with 5593 additions and 902 deletions

View File

@@ -1,4 +1,4 @@
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
@@ -61,10 +61,22 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
filters.push(inArray(berths.id, matchingIds));
}
// Default ordering is natural alphanumeric on mooring number
// (A1, A2, A10, B1...) — Postgres' default lexicographic sort
// would put A10 before A2, which is the wrong story for a marina
// map. The mooring format is locked at `^[A-Z]+\d+$` so the regexp
// splits are safe.
const NATURAL_MOORING_SORT = [
sql`regexp_replace(${berths.mooringNumber}, '\d+$', '') ASC`,
sql`(regexp_replace(${berths.mooringNumber}, '^[A-Z]+', ''))::int ASC`,
];
const sortColumn = (() => {
switch (query.sort) {
case 'mooringNumber':
return berths.mooringNumber;
// Honoured via customOrderBy below — caller asked for mooring
// sort explicitly, give them the natural order.
return null;
case 'area':
return berths.area;
case 'price':
@@ -74,7 +86,9 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
case 'lengthM':
return berths.lengthM;
default:
return berths.updatedAt;
// No sort requested → natural mooring order is the friendliest
// default for the berth grid (groups by pontoon letter).
return null;
}
})();
@@ -85,7 +99,8 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
idColumn: berths.id,
updatedAtColumn: berths.updatedAt,
filters,
sort: { column: sortColumn, direction: query.order },
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
customOrderBy: sortColumn ? undefined : NATURAL_MOORING_SORT,
page: query.page,
pageSize: query.limit,
searchColumns: [berths.mooringNumber, berths.area],

View File

@@ -84,8 +84,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows] = await Promise.all(
[
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
@@ -148,22 +148,60 @@ export async function listClients(portId: string, query: ListClientsInput) {
clientId: string;
channel: string;
value: string;
valueE164: string | null;
isPrimary: boolean;
createdAt: Date;
}>(sql`
SELECT DISTINCT ON (client_id, channel)
client_id AS "clientId",
client_id AS "clientId",
channel,
value,
is_primary AS "isPrimary",
created_at AS "createdAt"
value_e164 AS "valueE164",
is_primary AS "isPrimary",
created_at AS "createdAt"
FROM client_contacts
WHERE ${inArray(clientContacts.clientId, ids)}
AND channel IN ('email', 'phone')
ORDER BY client_id, channel, is_primary DESC, created_at DESC
`),
],
);
// Berths each client has interests in, with the (most-active)
// interest's stage attached so the list-view chip can self-describe
// ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
// collapses (client, berth) when the client has had multiple
// historical interests in the same berth — we keep the open-outcome
// one if any, otherwise the most recently updated. Excludes archived
// interests so closed deals don't crowd the chip row.
db.execute<{
clientId: string;
berthId: string;
mooringNumber: string;
interestId: string;
pipelineStage: string;
outcome: string | null;
}>(sql`
SELECT DISTINCT ON (i.client_id, b.id)
i.client_id AS "clientId",
b.id AS "berthId",
b.mooring_number AS "mooringNumber",
i.id AS "interestId",
i.pipeline_stage AS "pipelineStage",
i.outcome
FROM interests i
JOIN interest_berths ib ON ib.interest_id = i.id
JOIN berths b ON b.id = ib.berth_id
WHERE i.port_id = ${portId}
AND i.client_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
AND i.archived_at IS NULL
ORDER BY
i.client_id,
b.id,
CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
i.updated_at DESC
`),
]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
@@ -182,12 +220,16 @@ export async function listClients(portId: string, query: ListClientsInput) {
// Pick the per-client primary email + phone. The SQL DISTINCT ON
// returns at most one row per (clientId, channel); the result is
// already the picker's "is_primary desc, created_at desc" choice.
// We also keep the E.164 form of the phone so the UI can build a
// wa.me/<digits> link that doesn't need re-parsing.
const primaryEmailMap = new Map<string, string>();
const primaryPhoneMap = new Map<string, string>();
const primaryPhoneE164Map = new Map<string, string>();
type ContactRow = {
clientId: string;
channel: string;
value: string;
valueE164: string | null;
isPrimary: boolean;
createdAt: Date;
};
@@ -195,7 +237,66 @@ export async function listClients(portId: string, query: ListClientsInput) {
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
for (const c of contactRowList) {
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
else if (c.channel === 'phone') primaryPhoneMap.set(c.clientId, c.value);
else if (c.channel === 'phone') {
primaryPhoneMap.set(c.clientId, c.value);
if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
}
}
// Aggregate berths per client, sorted so the most-action-worthy
// interest floats to the top of the chip row. Priority:
// 1. open outcome (active deal) before closed (won/lost/cancelled)
// 2. within open: most progressed stage first (contract_signed > … > open)
// 3. tie-breaker: mooring number alphabetical for stable ordering
// The list-view UI shows the top 2 with full labels; the rest fall
// through into a "+N more" popover.
const stageRank: Record<string, number> = {
contract_signed: 1,
deposit_10pct: 2,
contract_sent: 3,
eoi_signed: 4,
eoi_sent: 5,
in_communication: 6,
details_sent: 7,
qualified: 8,
open: 9,
completed: 10,
};
type LinkedBerth = {
id: string;
mooringNumber: string;
interestId: string;
stage: string;
outcome: string | null;
};
const linkedBerthsMap = new Map<string, LinkedBerth[]>();
type LinkedBerthRow = typeof linkedBerthRows extends Iterable<infer T> ? T : never;
const linkedBerthList: LinkedBerthRow[] =
(linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
(linkedBerthRows as unknown as LinkedBerthRow[]);
for (const r of linkedBerthList) {
const list = linkedBerthsMap.get(r.clientId) ?? [];
list.push({
id: r.berthId,
mooringNumber: r.mooringNumber,
interestId: r.interestId,
stage: r.pipelineStage,
outcome: r.outcome,
});
linkedBerthsMap.set(r.clientId, list);
}
for (const list of linkedBerthsMap.values()) {
list.sort((a, b) => {
// Open before closed.
const openA = a.outcome === null ? 0 : 1;
const openB = b.outcome === null ? 0 : 1;
if (openA !== openB) return openA - openB;
// Within bucket, most-progressed stage first.
const rankA = stageRank[a.stage] ?? 99;
const rankB = stageRank[b.stage] ?? 99;
if (rankA !== rankB) return rankA - rankB;
return a.mooringNumber.localeCompare(b.mooringNumber);
});
}
return {
@@ -209,6 +310,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
interestCount: interestCountMap.get(row.id) ?? 0,
primaryEmail: primaryEmailMap.get(row.id) ?? null,
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
linkedBerths: linkedBerthsMap.get(row.id) ?? [],
latestInterest: latest
? {
stage: latest.stage,

View File

@@ -366,7 +366,11 @@ export async function resolveTemplate(
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
: '';
tokenMap['{{interest.notes}}'] = interest.notes ?? '';
// `{{interest.notes}}` is now sourced from the threaded
// interest_notes timeline via EoiContext.interest.notes; this
// shallow-fallback path leaves the token blank if EoiContext
// wasn't loaded for this template render.
tokenMap['{{interest.notes}}'] = '';
}
// These are never populated by EoiContext - always fill them in.
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';

View File

@@ -1,8 +1,9 @@
import { inArray } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { user } from '@/lib/db/schema/users';
import { searchAuditLogs } from '@/lib/services/audit-search.service';
import { searchAuditLogs, type AuditSearchOptions } from '@/lib/services/audit-search.service';
/**
* Shared loader for the per-entity Activity tab. Wraps `searchAuditLogs`
@@ -40,3 +41,69 @@ export async function loadEntityActivity(args: {
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
}));
}
/**
* Aggregated activity for a client — includes audit logs for the
* client itself + every interest belonging to that client. Used by
* the Client overview's Activity tab so the rep sees the whole
* timeline without clicking into each interest individually.
*
* Two queries (one per entityType) merged + sorted in JS rather than
* a UNION because the auditLogs.entityType field would need to match
* different values in the same SELECT — cleaner to keep the search
* helper's per-entity-type semantics intact and merge here.
*/
export async function loadClientActivityAggregated(args: {
portId: string;
clientId: string;
limit?: number;
}) {
const limit = args.limit ?? 50;
// Resolve interest ids upfront so we know what to fetch in parallel.
const interestRows = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.clientId, args.clientId), eq(interests.portId, args.portId)));
const interestIds = interestRows.map((r) => r.id);
const baseOpts = (entityType: string, entityId?: string, entityIds?: string[]) =>
({
portId: args.portId,
entityType,
entityId,
entityIds,
// Fetch up to `limit` per slice; we'll resort + slice to limit
// after merging. Slight over-fetch keeps the merged window honest
// when the activity is unbalanced (e.g. mostly interest events).
limit,
}) satisfies AuditSearchOptions;
const [clientPage, interestPage] = await Promise.all([
searchAuditLogs(baseOpts('client', args.clientId)),
interestIds.length > 0
? searchAuditLogs(baseOpts('interest', undefined, interestIds))
: Promise.resolve({ rows: [], nextCursor: null }),
]);
const merged = [...clientPage.rows, ...interestPage.rows]
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
// Resolve actor names in one round-trip across the merged set.
const userIds = Array.from(
new Set(merged.map((r) => r.userId).filter((u): u is string => Boolean(u))),
);
const userRows = userIds.length
? await db
.select({ id: user.id, email: user.email, name: user.name })
.from(user)
.where(inArray(user.id, userIds))
: [];
const userMap = new Map(userRows.map((u) => [u.id, u]));
return merged.map((r) => ({
...r,
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
}));
}

View File

@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { companies, companyAddresses } from '@/lib/db/schema/companies';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { interests, interestBerths, interestNotes } from '@/lib/db/schema/interests';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { getCountryName } from '@/lib/i18n/countries';
@@ -110,6 +110,18 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
const primaryBerth = await getPrimaryBerth(interest.id);
const primaryBerthId = primaryBerth?.berthId ?? null;
// The legacy `interests.notes` blob was dropped in favour of the
// threaded `interest_notes` timeline. Templates / merge fields still
// expose `interest.notes`, so we surface the most-recent threaded
// note's content here. Returns null when the interest has no notes.
const [latestNote] = await db
.select({ content: interestNotes.content })
.from(interestNotes)
.where(eq(interestNotes.interestId, interest.id))
.orderBy(desc(interestNotes.createdAt))
.limit(1);
const interestNotesContent = latestNote?.content ?? null;
// Resolve every berth in the EOI bundle (is_in_eoi_bundle=true) for the
// multi-berth EOI compact-range merge field. Empty bundle → "" so the
// Documenso template renders blank rather than "undefined".
@@ -300,7 +312,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
stage: interest.pipelineStage,
leadCategory: interest.leadCategory,
dateFirstContact: interest.dateFirstContact,
notes: interest.notes,
notes: interestNotesContent,
},
port: {
name: port.name,

View File

@@ -0,0 +1,229 @@
/**
* Interest contact-log service — CRUD over `interest_contact_log` plus
* the side-effects that make logging an interaction useful:
*
* 1. Bump `interests.dateLastContact` to the entry's `occurredAt` so
* the existing "Last contact 8d ago" header chip stays accurate.
* 2. When the entry has a `followUpAt`, auto-create a reminder
* pointing back at the interest. Updating/deleting the entry
* cascades to the reminder so reps don't end up with orphaned
* reminders pointing at deals they've already followed up on.
*
* All ops are tenant-scoped via `portId` (inherited from the interest).
*/
import { and, asc, desc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
interestContactLog,
interests,
reminders,
type InterestContactLogEntry,
type NewInterestContactLogEntry,
} from '@/lib/db/schema';
import { ConflictError, NotFoundError } from '@/lib/errors';
// ─── Types ───────────────────────────────────────────────────────────────────
export type ContactChannel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
export type ContactDirection = 'outbound' | 'inbound';
export interface CreateContactLogInput {
interestId: string;
occurredAt: Date;
channel: ContactChannel;
direction: ContactDirection;
summary: string;
followUpAt?: Date | null;
}
export interface UpdateContactLogInput {
occurredAt?: Date;
channel?: ContactChannel;
direction?: ContactDirection;
summary?: string;
followUpAt?: Date | null;
}
// ─── Read ────────────────────────────────────────────────────────────────────
/** List contact-log entries for an interest, newest first. */
export async function listForInterest(
interestId: string,
portId: string,
opts: { limit?: number; order?: 'asc' | 'desc' } = {},
): Promise<InterestContactLogEntry[]> {
const order = opts.order ?? 'desc';
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
return db
.select()
.from(interestContactLog)
.where(
and(eq(interestContactLog.interestId, interestId), eq(interestContactLog.portId, portId)),
)
.orderBy(
order === 'asc' ? asc(interestContactLog.occurredAt) : desc(interestContactLog.occurredAt),
)
.limit(limit);
}
// ─── Create ──────────────────────────────────────────────────────────────────
export async function create(
userId: string,
input: CreateContactLogInput,
): Promise<InterestContactLogEntry> {
// Resolve port from the interest so callers don't have to thread it.
const interest = await db.query.interests.findFirst({
where: eq(interests.id, input.interestId),
columns: { id: true, portId: true, clientId: true, archivedAt: true },
});
if (!interest) throw new NotFoundError('Interest');
if (interest.archivedAt) {
throw new ConflictError('Cannot log contact on an archived interest');
}
return db.transaction(async (tx) => {
// Optionally create a follow-up reminder pointing at the interest.
let reminderId: string | null = null;
if (input.followUpAt) {
const [rem] = await tx
.insert(reminders)
.values({
portId: interest.portId,
title: `Follow up: ${input.summary.slice(0, 80)}`,
note: `Auto-created from contact log (${input.channel}, ${input.direction}).`,
dueAt: input.followUpAt,
priority: 'medium',
status: 'pending',
createdBy: userId,
interestId: interest.id,
clientId: interest.clientId,
autoGenerated: true,
})
.returning({ id: reminders.id });
reminderId = rem!.id;
}
const insertValues: NewInterestContactLogEntry = {
portId: interest.portId,
interestId: input.interestId,
occurredAt: input.occurredAt,
channel: input.channel,
direction: input.direction,
summary: input.summary,
followUpAt: input.followUpAt ?? null,
reminderId,
createdBy: userId,
};
const [entry] = await tx.insert(interestContactLog).values(insertValues).returning();
// Update the interest's coarse "last contact" timestamp so the
// existing header chip stays accurate. Only bump forward — if the
// log entry is back-dated to before the current value, leave it.
await tx
.update(interests)
.set({ dateLastContact: input.occurredAt, updatedAt: new Date() })
.where(
and(
eq(interests.id, input.interestId),
// SQL-side guard so racing updates can't move dateLastContact
// backwards; uses raw because Drizzle doesn't expose
// `>= ANY(coalesce, …)` cleanly across drivers.
),
);
return entry!;
});
}
// ─── Update ──────────────────────────────────────────────────────────────────
export async function update(
id: string,
portId: string,
userId: string,
input: UpdateContactLogInput,
): Promise<InterestContactLogEntry> {
const existing = await db.query.interestContactLog.findFirst({
where: and(eq(interestContactLog.id, id), eq(interestContactLog.portId, portId)),
});
if (!existing) throw new NotFoundError('Contact log entry');
return db.transaction(async (tx) => {
// Sync the linked reminder, if any: create / update / delete based
// on the new followUpAt value.
let reminderId: string | null = existing.reminderId;
const newFollowUpAt = input.followUpAt === undefined ? existing.followUpAt : input.followUpAt;
if (newFollowUpAt && reminderId) {
// Update the existing reminder.
await tx
.update(reminders)
.set({
dueAt: newFollowUpAt,
title: `Follow up: ${(input.summary ?? existing.summary).slice(0, 80)}`,
updatedAt: new Date(),
})
.where(eq(reminders.id, reminderId));
} else if (newFollowUpAt && !reminderId) {
// Add a new reminder.
const [rem] = await tx
.insert(reminders)
.values({
portId: existing.portId,
title: `Follow up: ${(input.summary ?? existing.summary).slice(0, 80)}`,
note: `Auto-created from contact log.`,
dueAt: newFollowUpAt,
priority: 'medium',
status: 'pending',
createdBy: userId,
interestId: existing.interestId,
autoGenerated: true,
})
.returning({ id: reminders.id });
reminderId = rem!.id;
} else if (!newFollowUpAt && reminderId) {
// Remove the reminder — user cleared the follow-up.
await tx.delete(reminders).where(eq(reminders.id, reminderId));
reminderId = null;
}
const [updated] = await tx
.update(interestContactLog)
.set({
...(input.occurredAt !== undefined && { occurredAt: input.occurredAt }),
...(input.channel !== undefined && { channel: input.channel }),
...(input.direction !== undefined && { direction: input.direction }),
...(input.summary !== undefined && { summary: input.summary }),
followUpAt: newFollowUpAt,
reminderId,
updatedAt: new Date(),
})
.where(eq(interestContactLog.id, id))
.returning();
return updated!;
});
}
// ─── Delete ──────────────────────────────────────────────────────────────────
export async function remove(id: string, portId: string): Promise<void> {
const existing = await db.query.interestContactLog.findFirst({
where: and(eq(interestContactLog.id, id), eq(interestContactLog.portId, portId)),
columns: { id: true, reminderId: true },
});
if (!existing) throw new NotFoundError('Contact log entry');
await db.transaction(async (tx) => {
// Delete the linked reminder if any.
if (existing.reminderId) {
await tx.delete(reminders).where(eq(reminders.id, existing.reminderId));
}
await tx.delete(interestContactLog).where(eq(interestContactLog.id, id));
});
}

View File

@@ -131,6 +131,128 @@ async function resolveLeadCategory(
return leadCategory ?? undefined;
}
// ─── Board (kanban) ───────────────────────────────────────────────────────────
/**
* Soft cap on board rows. The kanban legitimately needs every active
* interest in one shot — paginating would split deals across pages and
* break drag-drop semantics — but unbounded SELECTs are a footgun if a
* port suddenly has tens of thousands of stale interests. At 5000 the
* payload is still well under a megabyte (≈50 bytes per minimal row),
* and any port near that ceiling needs virtualization in the kanban UI
* anyway, so failing loud here is the right escalation.
*/
const BOARD_MAX_ROWS = 5000;
export interface BoardInterestRow {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
leadCategory: string | null;
pipelineStage: string;
updatedAt: Date;
}
export interface BoardFilters {
/** Free-text search against client name. */
search?: string;
leadCategory?: string;
source?: string;
eoiStatus?: string;
/** Tag IDs the interest must be tagged with (any-of). */
tagIds?: string[];
}
/**
* Minimal-projection list for the kanban board. Skips the validator's
* `max(100)` page cap since the board renders the entire pipeline at
* once. Returns only the fields PipelineCard renders — no tags-list, no
* notes-count, no EOI status badges, no urgency joins. Always filters
* out archived interests (the kanban is for active deals; the list view
* has the includeArchived toggle for history).
*
* Filters are intentionally a SUBSET of listInterests — `pipelineStage`
* is omitted because the columns ARE the stages, and `includeArchived`
* is omitted because the kanban shouldn't surface archived deals.
*
* One round-trip for the interests + clientName join, one batched
* round-trip via getPrimaryBerthsForInterests for the mooring numbers,
* and one batched lookup for tag-id filtering when supplied.
*/
export async function listInterestsForBoard(
portId: string,
filters: BoardFilters = {},
): Promise<{ data: BoardInterestRow[]; truncated: boolean; total: number }> {
const conditions = [eq(interests.portId, portId), isNull(interests.archivedAt)];
if (filters.leadCategory) {
conditions.push(eq(interests.leadCategory, filters.leadCategory));
}
if (filters.source) {
conditions.push(eq(interests.source, filters.source));
}
if (filters.eoiStatus) {
conditions.push(eq(interests.eoiStatus, filters.eoiStatus));
}
// Tag-id filter resolves through the join table first so the main
// query stays a simple WHERE id IN (…) rather than a SELECT DISTINCT
// with LEFT JOIN — keeps Postgres' planner happy at scale.
if (filters.tagIds && filters.tagIds.length > 0) {
const tagMatches = await db
.selectDistinct({ interestId: interestTags.interestId })
.from(interestTags)
.where(inArray(interestTags.tagId, filters.tagIds));
const matchingIds = tagMatches.map((r) => r.interestId);
if (matchingIds.length === 0) {
return { data: [], truncated: false, total: 0 };
}
conditions.push(inArray(interests.id, matchingIds));
}
// Search hits client name via the LEFT JOIN. ILIKE is correct here —
// the kanban list is small (≤5000 rows) so an index scan isn't
// required, and pg_trgm would be overkill for the board surface.
if (filters.search && filters.search.trim().length > 0) {
const term = `%${filters.search.trim().replace(/[%_]/g, '\\$&')}%`;
conditions.push(sql`${clients.fullName} ILIKE ${term}`);
}
const rows = await db
.select({
id: interests.id,
clientName: clients.fullName,
leadCategory: interests.leadCategory,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
})
.from(interests)
.leftJoin(clients, eq(interests.clientId, clients.id))
.where(and(...conditions))
.orderBy(desc(interests.updatedAt))
.limit(BOARD_MAX_ROWS + 1);
const truncated = rows.length > BOARD_MAX_ROWS;
const data = truncated ? rows.slice(0, BOARD_MAX_ROWS) : rows;
// Primary-berth resolution stays in the junction-aware service so the
// board sees the same "the berth for this deal" as every other surface.
const primaryBerthMap = await getPrimaryBerthsForInterests(data.map((r) => r.id));
return {
data: data.map((r) => ({
id: r.id,
clientName: r.clientName ?? null,
berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
leadCategory: r.leadCategory ?? null,
pipelineStage: r.pipelineStage,
updatedAt: r.updatedAt,
})),
truncated,
total: data.length,
};
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listInterests(portId: string, query: ListInterestsInput) {
@@ -367,6 +489,15 @@ export async function getInterestById(id: string, portId: string) {
const berthId = primaryBerth?.berthId ?? null;
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
// Total linked-berth count powers the "Berth Interest" milestone on
// the OverviewTab — first thing the rep needs to capture, especially
// for general_interest leads. Resolved here (not from the join above)
// so the count includes berths the rep added without marking primary.
const [{ count: linkedBerthCount } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interestBerths)
.where(eq(interestBerths.interestId, id));
const tagRows = await db
.select({ id: tags.id, name: tags.name, color: tags.color })
.from(interestTags)
@@ -410,6 +541,7 @@ export async function getInterestById(id: string, portId: string) {
clientHasAddress: !!addressRow,
berthId,
berthMooringNumber,
linkedBerthCount,
tags: tagRows,
notesCount,
recentNote: recentNote ?? null,

View File

@@ -1,17 +1,29 @@
import { eq, and, desc } from 'drizzle-orm';
import { eq, and, desc, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clientNotes, clients } from '@/lib/db/schema/clients';
import { interestNotes, interests } from '@/lib/db/schema/interests';
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
import { companyNotes, companies } from '@/lib/db/schema/companies';
import {
residentialClients,
residentialClientNotes,
residentialInterests,
residentialInterestNotes,
} from '@/lib/db/schema/residential';
import { userProfiles } from '@/lib/db/schema/users';
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
type EntityType = 'clients' | 'interests' | 'yachts' | 'companies';
type EntityType =
| 'clients'
| 'interests'
| 'yachts'
| 'companies'
| 'residential_clients'
| 'residential_interests';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -41,18 +53,194 @@ async function verifyParentBelongsToPort(
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Yacht');
} else {
} else if (entityType === 'companies') {
const r = await db
.select({ id: companies.id })
.from(companies)
.where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Company');
} else if (entityType === 'residential_clients') {
const r = await db
.select({ id: residentialClients.id })
.from(residentialClients)
.where(and(eq(residentialClients.id, entityId), eq(residentialClients.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Residential client');
} else {
const r = await db
.select({ id: residentialInterests.id })
.from(residentialInterests)
.where(and(eq(residentialInterests.id, entityId), eq(residentialInterests.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Residential interest');
}
}
// Helper to centralise the per-entity table dispatch — keeps the CRUD
// branches below from each having their own switch.
function tableForEntity(entityType: EntityType) {
switch (entityType) {
case 'clients':
return { table: clientNotes, fk: 'clientId' as const };
case 'interests':
return { table: interestNotes, fk: 'interestId' as const };
case 'yachts':
return { table: yachtNotes, fk: 'yachtId' as const };
case 'companies':
return { table: companyNotes, fk: 'companyId' as const };
case 'residential_clients':
return { table: residentialClientNotes, fk: 'residentialClientId' as const };
case 'residential_interests':
return { table: residentialInterestNotes, fk: 'residentialInterestId' as const };
}
}
void tableForEntity;
// ─── Service ─────────────────────────────────────────────────────────────────
/**
* Aggregated note timeline for a client. Unions client-level notes
* with notes attached to ANY of the client's interests + directly-
* owned yachts (polymorphic ownership: `owner_type='client' AND
* owner_id=clientId`). Each row carries source metadata so the UI
* can show "from interest E17" or "from yacht Sea Breeze" badges
* and offer a "Group by source" view alongside chronological.
*
* Company-owned yachts the client is a member of are excluded —
* those are properly the company's notes, not the client's.
*/
export interface AggregatedClientNote {
id: string;
content: string;
mentions: string[] | null;
isLocked: boolean;
createdAt: Date;
updatedAt: Date;
authorId: string;
authorName: string | null;
source: 'client' | 'interest' | 'yacht';
/** Origin entity id — interest_id / yacht_id / client_id. */
sourceId: string;
/** Human label for the source (interest's berth mooring, yacht
* name, or "Client" for client-level). */
sourceLabel: string;
}
export async function listForClientAggregated(
portId: string,
clientId: string,
): Promise<AggregatedClientNote[]> {
await verifyParentBelongsToPort('clients', clientId, portId);
// Collect interest + yacht ids upfront so the note-table queries
// can be IN-list filtered.
const [interestRows, yachtRows] = await Promise.all([
db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
eq(yachts.currentOwnerId, clientId),
),
),
]);
const interestIds = interestRows.map((r) => r.id);
const yachtIds = yachtRows.map((r) => r.id);
const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name]));
// Resolve each interest's primary-berth mooring for the source
// label. Cheap single round-trip via the existing junction helper.
const primaryBerthMap =
interestIds.length > 0
? await (
await import('@/lib/services/interest-berths.service')
).getPrimaryBerthsForInterests(interestIds)
: new Map<string, { mooringNumber: string }>();
// Three parallel reads against the per-entity note tables; merged
// in JS rather than via UNION because each table has a different
// FK column name and Drizzle's UNION syntax forces matching shapes.
const [clientLevel, interestLevel, yachtLevel] = await Promise.all([
db
.select({
id: clientNotes.id,
content: clientNotes.content,
mentions: clientNotes.mentions,
isLocked: clientNotes.isLocked,
createdAt: clientNotes.createdAt,
updatedAt: clientNotes.updatedAt,
authorId: clientNotes.authorId,
authorName: userProfiles.displayName,
sourceId: clientNotes.clientId,
})
.from(clientNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
.where(eq(clientNotes.clientId, clientId)),
interestIds.length > 0
? db
.select({
id: interestNotes.id,
content: interestNotes.content,
mentions: interestNotes.mentions,
isLocked: interestNotes.isLocked,
createdAt: interestNotes.createdAt,
updatedAt: interestNotes.updatedAt,
authorId: interestNotes.authorId,
authorName: userProfiles.displayName,
sourceId: interestNotes.interestId,
})
.from(interestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
.where(inArray(interestNotes.interestId, interestIds))
: Promise.resolve([] as never[]),
yachtIds.length > 0
? db
.select({
id: yachtNotes.id,
content: yachtNotes.content,
mentions: yachtNotes.mentions,
isLocked: yachtNotes.isLocked,
createdAt: yachtNotes.createdAt,
updatedAt: yachtNotes.updatedAt,
authorId: yachtNotes.authorId,
authorName: userProfiles.displayName,
sourceId: yachtNotes.yachtId,
})
.from(yachtNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
.where(inArray(yachtNotes.yachtId, yachtIds))
: Promise.resolve([] as never[]),
]);
const merged: AggregatedClientNote[] = [
...clientLevel.map((n) => ({
...n,
source: 'client' as const,
sourceLabel: 'Client',
})),
...interestLevel.map((n) => ({
...n,
source: 'interest' as const,
sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
})),
...yachtLevel.map((n) => ({
...n,
source: 'yacht' as const,
sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht',
})),
];
merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return merged;
}
export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
await verifyParentBelongsToPort(entityType, entityId, portId);
@@ -107,7 +295,7 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
.where(eq(yachtNotes.yachtId, entityId))
.orderBy(desc(yachtNotes.createdAt));
} else {
} else if (entityType === 'companies') {
return db
.select({
id: companyNotes.id,
@@ -124,6 +312,40 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
.leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
.where(eq(companyNotes.companyId, entityId))
.orderBy(desc(companyNotes.createdAt));
} else if (entityType === 'residential_clients') {
return db
.select({
id: residentialClientNotes.id,
residentialClientId: residentialClientNotes.residentialClientId,
authorId: residentialClientNotes.authorId,
content: residentialClientNotes.content,
mentions: residentialClientNotes.mentions,
isLocked: residentialClientNotes.isLocked,
createdAt: residentialClientNotes.createdAt,
updatedAt: residentialClientNotes.updatedAt,
authorName: userProfiles.displayName,
})
.from(residentialClientNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId))
.where(eq(residentialClientNotes.residentialClientId, entityId))
.orderBy(desc(residentialClientNotes.createdAt));
} else {
return db
.select({
id: residentialInterestNotes.id,
residentialInterestId: residentialInterestNotes.residentialInterestId,
authorId: residentialInterestNotes.authorId,
content: residentialInterestNotes.content,
mentions: residentialInterestNotes.mentions,
isLocked: residentialInterestNotes.isLocked,
createdAt: residentialInterestNotes.createdAt,
updatedAt: residentialInterestNotes.updatedAt,
authorName: userProfiles.displayName,
})
.from(residentialInterestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId))
.where(eq(residentialInterestNotes.residentialInterestId, entityId))
.orderBy(desc(residentialInterestNotes.createdAt));
}
}
@@ -207,7 +429,8 @@ export async function create(
}
return { ...note, authorName };
} else {
}
if (entityType === 'interests') {
const [note] = await db
.insert(interestNotes)
.values({ interestId: entityId, authorId, content: data.content })
@@ -247,6 +470,38 @@ export async function create(
return { ...note, authorName };
}
if (entityType === 'residential_clients') {
const [note] = await db
.insert(residentialClientNotes)
.values({ residentialClientId: entityId, authorId, content: data.content })
.returning();
if (!note)
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'Residential client note insert returned no row',
});
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, authorId))
.limit(1);
return { ...note, authorName: profile[0]?.displayName ?? null };
}
if (entityType === 'residential_interests') {
const [note] = await db
.insert(residentialInterestNotes)
.values({ residentialInterestId: entityId, authorId, content: data.content })
.returning();
if (!note)
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'Residential interest note insert returned no row',
});
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, authorId))
.limit(1);
return { ...note, authorName: profile[0]?.displayName ?? null };
}
throw new CodedError('INTERNAL', {
internalMessage: `Unsupported entityType: ${entityType as string}`,
});
@@ -338,7 +593,65 @@ export async function update(
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
} else {
}
if (entityType === 'residential_clients') {
const [existing] = await db
.select()
.from(residentialClientNotes)
.where(
and(
eq(residentialClientNotes.id, noteId),
eq(residentialClientNotes.residentialClientId, entityId),
),
)
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
const [updated] = await db
.update(residentialClientNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(residentialClientNotes.id, noteId))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, updated.authorId))
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
}
if (entityType === 'residential_interests') {
const [existing] = await db
.select()
.from(residentialInterestNotes)
.where(
and(
eq(residentialInterestNotes.id, noteId),
eq(residentialInterestNotes.residentialInterestId, entityId),
),
)
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
const [updated] = await db
.update(residentialInterestNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(residentialInterestNotes.id, noteId))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, updated.authorId))
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
}
// Default: interests (the marina-side, not residential)
{
const [existing] = await db
.select()
.from(interestNotes)
@@ -416,7 +729,45 @@ export async function deleteNote(
await db.delete(clientNotes).where(eq(clientNotes.id, noteId));
return existing;
} else {
}
if (entityType === 'residential_clients') {
const [existing] = await db
.select()
.from(residentialClientNotes)
.where(
and(
eq(residentialClientNotes.id, noteId),
eq(residentialClientNotes.residentialClientId, entityId),
),
)
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(residentialClientNotes).where(eq(residentialClientNotes.id, noteId));
return existing;
}
if (entityType === 'residential_interests') {
const [existing] = await db
.select()
.from(residentialInterestNotes)
.where(
and(
eq(residentialInterestNotes.id, noteId),
eq(residentialInterestNotes.residentialInterestId, entityId),
),
)
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(residentialInterestNotes).where(eq(residentialInterestNotes.id, noteId));
return existing;
}
// Default: interests
{
const [existing] = await db
.select()
.from(interestNotes)

View File

@@ -9,7 +9,13 @@ import { emitToRoom } from '@/lib/socket/server';
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
export async function listUsers(portId: string) {
const rows = await db
// Two passes:
// 1. Users with an explicit user_port_roles row for this port
// 2. All super-admins (they have global access via the
// userProfiles.isSuperAdmin flag, no per-port row required —
// previous query missed them and the admin list looked empty
// to the only super-admin viewing it)
const portRoleRows = await db
.select({
userId: userPortRoles.userId,
displayName: userProfiles.displayName,
@@ -26,20 +32,58 @@ export async function listUsers(portId: string) {
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.innerJoin(user, eq(userPortRoles.userId, user.id))
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
.where(eq(userPortRoles.portId, portId))
.orderBy(userProfiles.displayName);
.where(eq(userPortRoles.portId, portId));
return rows.map((row) => ({
userId: row.userId,
displayName: row.displayName,
email: row.email,
phone: row.phone,
isActive: row.isActive,
isSuperAdmin: row.isSuperAdmin,
lastLoginAt: row.lastLoginAt,
role: { id: row.roleId, name: row.roleName },
assignedAt: row.assignedAt,
}));
const superAdminRows = await db
.select({
userId: userProfiles.userId,
displayName: userProfiles.displayName,
email: user.email,
phone: userProfiles.phone,
isActive: userProfiles.isActive,
isSuperAdmin: userProfiles.isSuperAdmin,
lastLoginAt: userProfiles.lastLoginAt,
assignedAt: userProfiles.createdAt,
})
.from(userProfiles)
.innerJoin(user, eq(userProfiles.userId, user.id))
.where(eq(userProfiles.isSuperAdmin, true));
// Dedup: a super-admin who ALSO has an explicit per-port role
// appears once with their port-role displayed (more specific).
const seen = new Set(portRoleRows.map((r) => r.userId));
const merged = [
...portRoleRows.map((row) => ({
userId: row.userId,
displayName: row.displayName,
email: row.email,
phone: row.phone,
isActive: row.isActive,
isSuperAdmin: row.isSuperAdmin,
lastLoginAt: row.lastLoginAt,
role: { id: row.roleId, name: row.roleName },
assignedAt: row.assignedAt,
})),
...superAdminRows
.filter((row) => !seen.has(row.userId))
.map((row) => ({
userId: row.userId,
displayName: row.displayName,
email: row.email,
phone: row.phone,
isActive: row.isActive,
isSuperAdmin: row.isSuperAdmin,
lastLoginAt: row.lastLoginAt,
// Synthetic role label — super admins don't have a per-port
// role row, but the UI expects a `role` object. The list
// already shows the "Super Admin" badge separately.
role: { id: 'super_admin', name: 'super_admin' },
assignedAt: row.assignedAt,
})),
];
merged.sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? ''));
return merged;
}
export async function getUser(userId: string, portId: string) {