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:
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
229
src/lib/services/interest-contact-log.service.ts
Normal file
229
src/lib/services/interest-contact-log.service.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user