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:
@@ -2,7 +2,11 @@ import { z } from 'zod';
|
||||
|
||||
export const baseListQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||
// Bumped from 100 to 1000 so the table page-size selector can offer
|
||||
// an "All" option that maps to a single big fetch. Above 1000 the
|
||||
// caller must paginate; anything routinely north of that ceiling
|
||||
// needs virtualization rather than a bigger page-size cap.
|
||||
limit: z.coerce.number().int().min(1).max(1000).default(25),
|
||||
sort: z.string().optional(),
|
||||
order: z.enum(['asc', 'desc']).default('desc'),
|
||||
search: z.string().optional(),
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
isNull,
|
||||
or,
|
||||
sql,
|
||||
type SQL,
|
||||
} from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
|
||||
import { db } from './index';
|
||||
|
||||
@@ -20,6 +10,13 @@ export interface BuildListQueryOptions {
|
||||
updatedAtColumn: PgColumn;
|
||||
filters?: SQL[];
|
||||
sort?: { column: PgColumn; direction: 'asc' | 'desc' };
|
||||
/**
|
||||
* Custom ORDER BY clauses, used INSTEAD of `sort`. For cases where
|
||||
* the natural ordering needs raw SQL (e.g. natural alphanumeric sort
|
||||
* on berth mooring numbers like A1, A2, A10, B1...). Deterministic
|
||||
* tail-sort on `updatedAt DESC, id DESC` is still appended.
|
||||
*/
|
||||
customOrderBy?: SQL[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
searchColumns?: PgColumn[];
|
||||
@@ -40,9 +37,7 @@ export interface ListResult<T> {
|
||||
* - `archivedAt IS NULL` by default (unless `includeArchived` is true).
|
||||
* - Deterministic secondary sort: `updatedAt DESC, id DESC`.
|
||||
*/
|
||||
export async function buildListQuery<T>(
|
||||
opts: BuildListQueryOptions,
|
||||
): Promise<ListResult<T>> {
|
||||
export async function buildListQuery<T>(opts: BuildListQueryOptions): Promise<ListResult<T>> {
|
||||
const {
|
||||
table,
|
||||
portIdColumn,
|
||||
@@ -51,6 +46,7 @@ export async function buildListQuery<T>(
|
||||
updatedAtColumn,
|
||||
filters = [],
|
||||
sort,
|
||||
customOrderBy,
|
||||
page,
|
||||
pageSize,
|
||||
searchColumns = [],
|
||||
@@ -68,9 +64,7 @@ export async function buildListQuery<T>(
|
||||
|
||||
// Full-text search across multiple columns via ILIKE
|
||||
if (searchTerm && searchColumns.length > 0) {
|
||||
const searchConditions = searchColumns.map((col) =>
|
||||
ilike(col, `%${searchTerm}%`),
|
||||
);
|
||||
const searchConditions = searchColumns.map((col) => ilike(col, `%${searchTerm}%`));
|
||||
conditions.push(or(...searchConditions)!);
|
||||
}
|
||||
|
||||
@@ -86,12 +80,13 @@ export async function buildListQuery<T>(
|
||||
.where(where);
|
||||
const total = countResult[0]?.count ?? 0;
|
||||
|
||||
// Build order by: user sort + deterministic secondary sort
|
||||
// Build order by: customOrderBy (if provided) wins over the default
|
||||
// column-based sort. Deterministic secondary sort always trails.
|
||||
const orderClauses: SQL[] = [];
|
||||
if (sort) {
|
||||
orderClauses.push(
|
||||
sort.direction === 'asc' ? asc(sort.column) : desc(sort.column),
|
||||
);
|
||||
if (customOrderBy && customOrderBy.length > 0) {
|
||||
orderClauses.push(...customOrderBy);
|
||||
} else if (sort) {
|
||||
orderClauses.push(sort.direction === 'asc' ? asc(sort.column) : desc(sort.column));
|
||||
}
|
||||
orderClauses.push(desc(updatedAtColumn), desc(idColumn));
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ export const interests = pgTable(
|
||||
outcomeReason: text('outcome_reason'),
|
||||
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
|
||||
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
|
||||
notes: text('notes'),
|
||||
/** Recommender inputs - imperial; resolver treats nulls as "no constraint"
|
||||
* on that axis, with a banner prompting the rep to add the missing dim. */
|
||||
desiredLengthFt: numeric('desired_length_ft'),
|
||||
|
||||
@@ -143,10 +143,27 @@ export type RolePermissions = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-table column visibility — drives the `<ColumnPicker>` and the
|
||||
* DataTable `columnVisibility` state. `hiddenColumns` is the source of
|
||||
* truth; an entry's absence means "show this column" (so newly-added
|
||||
* columns show by default for existing users without us having to
|
||||
* migrate stored preferences).
|
||||
*/
|
||||
export type TablePreferences = {
|
||||
hiddenColumns?: string[];
|
||||
};
|
||||
|
||||
export type UserPreferences = {
|
||||
dark_mode?: boolean;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
/** ISO-3166-1 alpha-2. Drives the default timezone when the rep
|
||||
* hasn't picked one explicitly, and lets the auto-detect banner
|
||||
* spot a mismatch when they're travelling. */
|
||||
country?: string;
|
||||
/** Keyed by entity type: `clients`, `yachts`, `interests`, etc. */
|
||||
tablePreferences?: Record<string, TablePreferences>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -209,6 +226,12 @@ export const userProfiles = pgTable(
|
||||
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
||||
displayName: text('display_name').notNull(),
|
||||
avatarUrl: text('avatar_url'),
|
||||
/** FK into the polymorphic `files` table — the avatar is stored
|
||||
* via getStorageBackend() so an S3↔filesystem swap carries it
|
||||
* without breaking the URL. The legacy `avatarUrl` column is
|
||||
* kept for any external photo sources but the file pointer wins
|
||||
* when both are set. */
|
||||
avatarFileId: text('avatar_file_id'),
|
||||
phone: text('phone'),
|
||||
isSuperAdmin: boolean('is_super_admin').notNull().default(false),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
@@ -261,6 +284,37 @@ export const portRoleOverrides = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pending email-change records for the verify-old-and-new flow.
|
||||
* The CRM's `/api/v1/me/email` endpoint creates a row here, emails
|
||||
* the OLD address with a cancel link and the NEW address with a
|
||||
* confirm link, and applies the change only when the new address
|
||||
* confirms (or auto-cancels at `expiresAt`).
|
||||
*
|
||||
* `confirmTokenHash` stores a sha256 of the random confirmation
|
||||
* token; the raw token is only present in the email body.
|
||||
*/
|
||||
export const userEmailChanges = pgTable(
|
||||
'user_email_changes',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
userId: text('user_id').notNull(),
|
||||
oldEmail: text('old_email').notNull(),
|
||||
newEmail: text('new_email').notNull(),
|
||||
confirmTokenHash: text('confirm_token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
appliedAt: timestamp('applied_at', { withTimezone: true }),
|
||||
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_uec_user').on(table.userId),
|
||||
index('idx_uec_token').on(table.confirmTokenHash),
|
||||
],
|
||||
);
|
||||
|
||||
export const userPortRoles = pgTable(
|
||||
'user_port_roles',
|
||||
{
|
||||
|
||||
@@ -87,7 +87,9 @@ export interface SyntheticSeedSummary {
|
||||
}
|
||||
|
||||
interface SyntheticClientSpec {
|
||||
/** Used as a name suffix so test selectors can target it deterministically. */
|
||||
/** Stable identifier used by Playwright selectors and intra-seed wiring
|
||||
* (memberships, yachts, etc.). Decoupled from the display name so the
|
||||
* rendered list looks like real client data, not test fixtures. */
|
||||
tag: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
@@ -105,6 +107,14 @@ interface SyntheticClientSpec {
|
||||
/** Archive the CLIENT after creation. When 'rich', fabricate
|
||||
* archive_metadata so the smart-restore wizard surfaces reversals. */
|
||||
archive?: 'simple' | 'rich';
|
||||
/** Acquisition source — varied across the fixture set so the list view
|
||||
* looks like a real funnel rather than a wall of "Manual". */
|
||||
source?: 'website' | 'manual' | 'referral' | 'broker';
|
||||
/** How long ago (in days) this client record was created. Spreads the
|
||||
* "Created" column across realistic timestamps so list pages look like
|
||||
* a real CRM with months of history rather than 12 rows all stamped
|
||||
* with today's date. */
|
||||
createdDaysAgo?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,150 +125,184 @@ interface SyntheticClientSpec {
|
||||
* Berth indices map deterministically into the NocoDB snapshot which is
|
||||
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold.
|
||||
*/
|
||||
/**
|
||||
* Believable demo dataset — names, emails, phone numbers, addresses, and
|
||||
* acquisition sources read like a real marina's prospect list rather
|
||||
* than fixtures keyed on enum names. The `tag` field still carries the
|
||||
* stage/role identity for selectors and intra-seed wiring; nothing in
|
||||
* the rendered UI references it.
|
||||
*
|
||||
* Spread across acquisition sources, ages (3–280 days), and countries
|
||||
* so list / dashboard / kanban surfaces look populated and natural.
|
||||
*/
|
||||
const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
{
|
||||
tag: 'open',
|
||||
fullName: 'Olivia Open — open',
|
||||
email: 'olivia.open@test.local',
|
||||
phone: '+1 555 010 0001',
|
||||
fullName: 'Olivia Sinclair',
|
||||
email: 'olivia.sinclair@gmail.com',
|
||||
phone: '+44 7700 900142',
|
||||
countryIso: 'GB',
|
||||
city: 'London',
|
||||
street: '1 Open Lane',
|
||||
postalCode: 'OP1 1OP',
|
||||
street: '14 Cheyne Walk',
|
||||
postalCode: 'SW3 5RA',
|
||||
stage: 'open',
|
||||
source: 'website',
|
||||
createdDaysAgo: 4,
|
||||
// Open stage: no berth link yet
|
||||
},
|
||||
{
|
||||
tag: 'details',
|
||||
fullName: 'Daniel Details — details_sent',
|
||||
email: 'daniel.details@test.local',
|
||||
phone: '+1 555 010 0002',
|
||||
fullName: 'Daniel Whitaker',
|
||||
email: 'daniel.whitaker@outlook.com',
|
||||
phone: '+1 305 555 0182',
|
||||
countryIso: 'US',
|
||||
city: 'Miami',
|
||||
street: '2 Brochure Way',
|
||||
postalCode: '33101',
|
||||
street: '880 Brickell Bay Drive',
|
||||
postalCode: '33131',
|
||||
stage: 'details_sent',
|
||||
berthIdx: 0,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 12,
|
||||
},
|
||||
{
|
||||
tag: 'comms',
|
||||
fullName: 'Carla Communicating — in_communication',
|
||||
email: 'carla.comms@test.local',
|
||||
phone: '+1 555 010 0003',
|
||||
fullName: 'Carla Mendoza',
|
||||
email: 'carla.mendoza@gmail.com',
|
||||
phone: '+34 971 555 028',
|
||||
countryIso: 'ES',
|
||||
city: 'Palma',
|
||||
street: '3 Reply Street',
|
||||
postalCode: '07012',
|
||||
city: 'Palma de Mallorca',
|
||||
street: 'Carrer de Sant Magí 23',
|
||||
postalCode: '07013',
|
||||
stage: 'in_communication',
|
||||
berthIdx: 5,
|
||||
source: 'referral',
|
||||
createdDaysAgo: 28,
|
||||
},
|
||||
{
|
||||
tag: 'eoi-sent',
|
||||
fullName: 'Eric EoiSent — eoi_sent',
|
||||
email: 'eric.eoisent@test.local',
|
||||
phone: '+1 555 010 0004',
|
||||
fullName: 'Marco Bianchi',
|
||||
email: 'marco.bianchi@libero.it',
|
||||
phone: '+39 010 8740 215',
|
||||
countryIso: 'IT',
|
||||
city: 'Genoa',
|
||||
street: '4 Envelope Plaza',
|
||||
postalCode: '16124',
|
||||
street: 'Via XX Settembre 47',
|
||||
postalCode: '16121',
|
||||
stage: 'eoi_sent',
|
||||
berthIdx: 6,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 45,
|
||||
},
|
||||
{
|
||||
tag: 'eoi-signed',
|
||||
fullName: 'Sara EoiSigned — eoi_signed',
|
||||
email: 'sara.eoisigned@test.local',
|
||||
phone: '+1 555 010 0005',
|
||||
fullName: 'Sara Laurent',
|
||||
email: 'sara.laurent@orange.fr',
|
||||
phone: '+33 4 93 92 18 47',
|
||||
countryIso: 'FR',
|
||||
city: 'Nice',
|
||||
street: '5 Signed Avenue',
|
||||
postalCode: '06300',
|
||||
street: '8 Promenade des Anglais',
|
||||
postalCode: '06000',
|
||||
stage: 'eoi_signed',
|
||||
berthIdx: 7,
|
||||
source: 'website',
|
||||
createdDaysAgo: 72,
|
||||
},
|
||||
{
|
||||
tag: 'deposit',
|
||||
fullName: 'Dario Deposit — deposit_10pct',
|
||||
email: 'dario.deposit@test.local',
|
||||
phone: '+1 555 010 0006',
|
||||
fullName: 'Nikolas Papadakis',
|
||||
email: 'n.papadakis@gmail.com',
|
||||
phone: '+30 210 8945 612',
|
||||
countryIso: 'GR',
|
||||
city: 'Athens',
|
||||
street: '6 Deposit Quay',
|
||||
postalCode: '10558',
|
||||
street: 'Vouliagmenis Avenue 142',
|
||||
postalCode: '16674',
|
||||
stage: 'deposit_10pct',
|
||||
berthIdx: 8,
|
||||
source: 'referral',
|
||||
createdDaysAgo: 95,
|
||||
},
|
||||
{
|
||||
tag: 'contract-sent',
|
||||
fullName: 'Connor ContractSent — contract_sent',
|
||||
email: 'connor.contract@test.local',
|
||||
phone: '+1 555 010 0007',
|
||||
fullName: 'Connor Murphy',
|
||||
email: 'connor.murphy@me.com',
|
||||
phone: '+353 1 555 0184',
|
||||
countryIso: 'IE',
|
||||
city: 'Dublin',
|
||||
street: '7 Contract Row',
|
||||
street: '12 Merrion Square North',
|
||||
postalCode: 'D02 E2X3',
|
||||
stage: 'contract_sent',
|
||||
berthIdx: 9,
|
||||
source: 'manual',
|
||||
createdDaysAgo: 118,
|
||||
},
|
||||
{
|
||||
tag: 'contract-signed',
|
||||
fullName: 'Carmen ContractSigned — contract_signed',
|
||||
email: 'carmen.signed@test.local',
|
||||
phone: '+1 555 010 0008',
|
||||
fullName: 'Carmen Costa',
|
||||
email: 'carmen.costa@sapo.pt',
|
||||
phone: '+351 21 386 4520',
|
||||
countryIso: 'PT',
|
||||
city: 'Lisbon',
|
||||
street: '8 Notary Square',
|
||||
postalCode: '1100-001',
|
||||
street: 'Rua Garrett 88',
|
||||
postalCode: '1200-205',
|
||||
stage: 'contract_signed',
|
||||
berthIdx: 4,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 156,
|
||||
},
|
||||
{
|
||||
tag: 'completed-won',
|
||||
fullName: 'Carlos Completed — completed (won)',
|
||||
email: 'carlos.complete@test.local',
|
||||
phone: '+1 555 010 0009',
|
||||
fullName: 'Carlos Vega',
|
||||
email: 'carlos.vega@gmail.com',
|
||||
phone: '+507 6612 4485',
|
||||
countryIso: 'PA',
|
||||
city: 'Panama City',
|
||||
street: '9 Owner Lane',
|
||||
postalCode: '0801',
|
||||
street: 'Calle 50, Torre Banistmo Piso 18',
|
||||
postalCode: '0816',
|
||||
stage: 'completed',
|
||||
berthIdx: 10,
|
||||
outcome: 'won',
|
||||
source: 'referral',
|
||||
createdDaysAgo: 245,
|
||||
},
|
||||
{
|
||||
tag: 'completed-lost',
|
||||
fullName: 'Lara LostLead — completed (lost)',
|
||||
email: 'lara.lost@test.local',
|
||||
phone: '+1 555 010 0010',
|
||||
fullName: 'Hannah Schmidt',
|
||||
email: 'hannah.schmidt@gmx.de',
|
||||
phone: '+49 40 4286 9152',
|
||||
countryIso: 'DE',
|
||||
city: 'Hamburg',
|
||||
street: '10 Other Marina',
|
||||
postalCode: '20457',
|
||||
street: 'Alsterufer 28',
|
||||
postalCode: '20354',
|
||||
stage: 'completed',
|
||||
berthIdx: 1,
|
||||
outcome: 'lost_unqualified',
|
||||
source: 'website',
|
||||
createdDaysAgo: 84,
|
||||
},
|
||||
{
|
||||
tag: 'archived-simple',
|
||||
fullName: 'Anna ArchivedSimple — archived',
|
||||
email: 'anna.archived@test.local',
|
||||
phone: '+1 555 010 0011',
|
||||
fullName: 'Anna de Jong',
|
||||
email: 'anna.dejong@kpn.nl',
|
||||
phone: '+31 20 624 7185',
|
||||
countryIso: 'NL',
|
||||
city: 'Amsterdam',
|
||||
street: '11 Quiet Path',
|
||||
postalCode: '1011',
|
||||
street: 'Herengracht 412',
|
||||
postalCode: '1017 BX',
|
||||
archive: 'simple',
|
||||
source: 'website',
|
||||
createdDaysAgo: 201,
|
||||
},
|
||||
{
|
||||
tag: 'archived-rich',
|
||||
fullName: 'Rita ArchivedRich — archived w/ metadata',
|
||||
email: 'rita.archivedrich@test.local',
|
||||
phone: '+1 555 010 0012',
|
||||
fullName: 'Rita Vermeulen',
|
||||
email: 'rita.vermeulen@telenet.be',
|
||||
phone: '+32 3 226 8420',
|
||||
countryIso: 'BE',
|
||||
city: 'Antwerp',
|
||||
street: '12 Rich Metadata Blvd',
|
||||
street: 'Meir 102',
|
||||
postalCode: '2000',
|
||||
archive: 'rich',
|
||||
source: 'broker',
|
||||
createdDaysAgo: 280,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -358,14 +402,25 @@ export async function seedSyntheticPortData(
|
||||
const clientRows = await tx
|
||||
.insert(clients)
|
||||
.values(
|
||||
PIPELINE_CLIENTS.map((spec) => ({
|
||||
portId,
|
||||
fullName: spec.fullName,
|
||||
nationalityIso: spec.countryIso,
|
||||
preferredContactMethod: 'email' as const,
|
||||
preferredLanguage: 'en',
|
||||
source: 'manual' as const,
|
||||
})),
|
||||
PIPELINE_CLIENTS.map((spec) => {
|
||||
const created =
|
||||
spec.createdDaysAgo !== undefined ? daysAgo(spec.createdDaysAgo) : new Date();
|
||||
return {
|
||||
portId,
|
||||
fullName: spec.fullName,
|
||||
nationalityIso: spec.countryIso,
|
||||
preferredContactMethod: 'email' as const,
|
||||
preferredLanguage: 'en',
|
||||
source: spec.source ?? ('manual' as const),
|
||||
// Override the schema default so the list page shows a
|
||||
// realistic range of "Created" timestamps rather than 12
|
||||
// rows all stamped with today's date. updated_at gets the
|
||||
// same value so sorted-by-recency lists put the freshest
|
||||
// records first.
|
||||
createdAt: created,
|
||||
updatedAt: created,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning({ id: clients.id, fullName: clients.fullName });
|
||||
|
||||
|
||||
@@ -310,7 +310,6 @@ async function applyInterest(
|
||||
pipelineStage: planned.pipelineStage,
|
||||
leadCategory: planned.leadCategory,
|
||||
source: planned.source,
|
||||
notes: planned.notes,
|
||||
documensoId: planned.documensoId,
|
||||
dateEoiSent: planned.dateEoiSent ? new Date(planned.dateEoiSent) : null,
|
||||
dateEoiSigned: planned.dateEoiSigned ? new Date(planned.dateEoiSigned) : null,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
28
src/lib/validators/interest-contact-log.ts
Normal file
28
src/lib/validators/interest-contact-log.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const CHANNELS = ['email', 'phone', 'whatsapp', 'in_person', 'video', 'other'] as const;
|
||||
const DIRECTIONS = ['outbound', 'inbound'] as const;
|
||||
|
||||
/** Cap summary length so a rep can't accidentally paste a 10MB email body. */
|
||||
const SUMMARY_MAX = 4000;
|
||||
|
||||
export const createContactLogSchema = z.object({
|
||||
occurredAt: z.coerce.date(),
|
||||
channel: z.enum(CHANNELS),
|
||||
direction: z.enum(DIRECTIONS).default('outbound'),
|
||||
summary: z.string().min(1).max(SUMMARY_MAX),
|
||||
followUpAt: z.coerce.date().optional().nullable(),
|
||||
});
|
||||
|
||||
export const updateContactLogSchema = z
|
||||
.object({
|
||||
occurredAt: z.coerce.date(),
|
||||
channel: z.enum(CHANNELS),
|
||||
direction: z.enum(DIRECTIONS),
|
||||
summary: z.string().min(1).max(SUMMARY_MAX),
|
||||
followUpAt: z.coerce.date().nullable(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type CreateContactLogPayload = z.infer<typeof createContactLogSchema>;
|
||||
export type UpdateContactLogPayload = z.infer<typeof updateContactLogSchema>;
|
||||
@@ -33,7 +33,6 @@ export const createInterestSchema = z.object({
|
||||
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
|
||||
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
tagIds: z.array(z.string()).optional().default([]),
|
||||
// Omitting reminderEnabled / reminderDays falls back to the per-port
|
||||
// defaults configured at /admin/reminders (resolved in
|
||||
@@ -102,6 +101,27 @@ export const listInterestsSchema = baseListQuerySchema.extend({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ─── Board (kanban) ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filters accepted by GET /api/v1/interests/board. Strict subset of
|
||||
* listInterestsSchema — `pipelineStage` and `includeArchived` are
|
||||
* intentionally omitted (the columns ARE the stages, archived deals
|
||||
* never belong on the board). No pagination params either.
|
||||
*/
|
||||
export const boardFiltersSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
|
||||
source: z.string().optional(),
|
||||
eoiStatus: z.string().optional(),
|
||||
tagIds: z
|
||||
.string()
|
||||
.transform((v) => v.split(',').filter(Boolean))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type BoardFiltersInput = z.infer<typeof boardFiltersSchema>;
|
||||
|
||||
// ─── Waiting List ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const waitingListAddSchema = z.object({
|
||||
@@ -192,7 +212,6 @@ export const publicInterestSchema = z
|
||||
// membership linking the submitting client to it.
|
||||
company: publicCompanySchema.optional(),
|
||||
source: z.literal('website').default('website'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
address: addressSchema.optional(),
|
||||
})
|
||||
.refine((data) => data.fullName || (data.firstName && data.lastName), {
|
||||
|
||||
Reference in New Issue
Block a user