/** * Custom-report entity registry. * * The custom builder is the catch-all for slices the four canonical * reports don't cover — pick an entity, pick columns, optionally * filter by date, get a CSV. v1 ships with the four highest-value * entities (clients, interests, berths, tenancies); the remaining six * from the launch-readiness scope (companies, yachts, invoices, * payments, deals, sends) layer in as their schemas are wired. * * Each entity defines: * - `columns`: an allowlist of column keys + human labels + a * resolver that extracts the value from a fetched row. The * allowlist matters: it gates which fields a rep can pull into a * CSV, so PII columns can be opt-in per role later. * - `runQuery`: a Drizzle select that joins whatever the columns * need, applies the port filter + optional date range, and * returns raw rows. * * Adding a new entity: * 1. Append it to ENTITY_KEYS. * 2. Add a CustomEntityDefinition entry to ENTITY_REGISTRY. * 3. Update the UI's entity-picker (it reads ENTITY_REGISTRY directly). */ import { and, asc, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm'; import { db } from '@/lib/db'; import { berths } from '@/lib/db/schema/berths'; import { clients } from '@/lib/db/schema/clients'; import { interests, interestBerths } from '@/lib/db/schema/interests'; import { berthTenancies as tenancies } from '@/lib/db/schema/tenancies'; import { STAGE_LABELS, type PipelineStage } from '@/lib/constants'; export const ENTITY_KEYS = ['clients', 'interests', 'berths', 'tenancies'] as const; export type EntityKey = (typeof ENTITY_KEYS)[number]; export interface CustomFilter { /** ISO 8601 — inclusive lower bound on the entity's "date" column * (createdAt or equivalent — see entity definition). */ from?: Date; /** ISO 8601 — inclusive upper bound. */ to?: Date; } export interface ColumnDefinition { /** Stable key. Persisted in saved-template configs. */ key: string; /** Human-readable column header used in CSV/PDF output + the UI * multi-select. */ label: string; /** Default selection in the UI. Reps can uncheck. */ defaultSelected?: boolean; } export interface CustomEntityDefinition { key: EntityKey; label: string; description: string; /** Friendly name for the date filter — different entities anchor * the date range to different timestamps. */ dateAxis: string; columns: ColumnDefinition[]; /** Execute the underlying query and return raw rows keyed by column * key. The runner is responsible for the joins + port scoping; * callers only pass which columns they want + the filter. */ runQuery: (input: { portId: string; columns: string[]; filter: CustomFilter; }) => Promise>>; } // ─── Helpers ───────────────────────────────────────────────────────────────── function applyDateRange(column: ReturnType>, filter: CustomFilter): SQL[] { const conds: SQL[] = []; if (filter.from) conds.push(gte(column as never, filter.from)); if (filter.to) conds.push(lte(column as never, filter.to)); return conds; } // ─── Clients ───────────────────────────────────────────────────────────────── const CLIENTS_COLUMNS: ColumnDefinition[] = [ { key: 'fullName', label: 'Full name', defaultSelected: true }, { key: 'nationalityIso', label: 'Nationality', defaultSelected: false }, { key: 'preferredLanguage', label: 'Preferred language' }, { key: 'preferredContactMethod', label: 'Preferred contact', defaultSelected: false }, { key: 'source', label: 'Source', defaultSelected: true }, { key: 'createdAt', label: 'Created', defaultSelected: true }, { key: 'archivedAt', label: 'Archived at' }, ]; async function runClientsQuery({ portId, filter, }: { portId: string; columns: string[]; filter: CustomFilter; }): Promise>> { const conds = [eq(clients.portId, portId), ...applyDateRange(clients.createdAt as never, filter)]; const rows = await db .select({ fullName: clients.fullName, nationalityIso: clients.nationalityIso, preferredLanguage: clients.preferredLanguage, preferredContactMethod: clients.preferredContactMethod, source: clients.source, createdAt: clients.createdAt, archivedAt: clients.archivedAt, }) .from(clients) .where(and(...conds)) .orderBy(asc(clients.fullName)) .limit(10_000); return rows.map((r) => ({ ...r })); } // ─── Interests ─────────────────────────────────────────────────────────────── const INTERESTS_COLUMNS: ColumnDefinition[] = [ { key: 'clientName', label: 'Client', defaultSelected: true }, { key: 'primaryBerth', label: 'Primary berth', defaultSelected: true }, { key: 'pipelineStage', label: 'Stage', defaultSelected: true }, { key: 'leadCategory', label: 'Lead category' }, { key: 'outcome', label: 'Outcome', defaultSelected: true }, { key: 'source', label: 'Source', defaultSelected: false }, { key: 'depositExpectedAmount', label: 'Deposit expected (amt)', defaultSelected: false }, { key: 'depositExpectedCurrency', label: 'Deposit expected (ccy)' }, { key: 'dateFirstContact', label: 'First contact', defaultSelected: false }, { key: 'dateLastContact', label: 'Last contact', defaultSelected: false }, { key: 'createdAt', label: 'Created', defaultSelected: true }, ]; async function runInterestsQuery({ portId, filter, }: { portId: string; columns: string[]; filter: CustomFilter; }): Promise>> { const conds = [ eq(interests.portId, portId), ...applyDateRange(interests.createdAt as never, filter), ]; const rows = await db .select({ clientName: clients.fullName, primaryBerth: berths.mooringNumber, pipelineStage: interests.pipelineStage, leadCategory: interests.leadCategory, outcome: interests.outcome, source: interests.source, depositExpectedAmount: interests.depositExpectedAmount, depositExpectedCurrency: interests.depositExpectedCurrency, dateFirstContact: interests.dateFirstContact, dateLastContact: interests.dateLastContact, createdAt: interests.createdAt, }) .from(interests) .innerJoin(clients, eq(interests.clientId, clients.id)) .leftJoin( interestBerths, and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)), ) .leftJoin(berths, eq(interestBerths.berthId, berths.id)) .where(and(...conds)) .orderBy(desc(interests.createdAt)) .limit(10_000); return rows.map((r) => ({ ...r, // Re-label stage to the human form so the CSV is readable; // analysts can still join back via the raw enum on display. pipelineStage: r.pipelineStage ? (STAGE_LABELS[r.pipelineStage as PipelineStage] ?? r.pipelineStage) : null, })); } // ─── Berths ────────────────────────────────────────────────────────────────── const BERTHS_COLUMNS: ColumnDefinition[] = [ { key: 'mooringNumber', label: 'Mooring', defaultSelected: true }, { key: 'area', label: 'Area' }, { key: 'status', label: 'Status', defaultSelected: true }, { key: 'length', label: 'Length (m)' }, { key: 'width', label: 'Width (m)' }, { key: 'draft', label: 'Draft (m)' }, { key: 'price', label: 'Price', defaultSelected: true }, { key: 'priceCurrency', label: 'Currency' }, { key: 'createdAt', label: 'Created' }, ]; async function runBerthsQuery({ portId, filter, }: { portId: string; columns: string[]; filter: CustomFilter; }): Promise>> { const conds = [eq(berths.portId, portId), ...applyDateRange(berths.createdAt as never, filter)]; const rows = await db .select({ mooringNumber: berths.mooringNumber, area: berths.area, status: berths.status, length: berths.lengthM, width: berths.widthM, draft: berths.draftM, price: berths.price, priceCurrency: berths.priceCurrency, createdAt: berths.createdAt, }) .from(berths) .where(and(...conds)) .orderBy(asc(berths.mooringNumber)) .limit(10_000); return rows.map((r) => ({ ...r })); } // ─── Tenancies ─────────────────────────────────────────────────────────────── const TENANCIES_COLUMNS: ColumnDefinition[] = [ { key: 'clientName', label: 'Client', defaultSelected: true }, { key: 'mooringNumber', label: 'Berth', defaultSelected: true }, { key: 'tenureType', label: 'Tenure type', defaultSelected: true }, { key: 'startDate', label: 'Start', defaultSelected: true }, { key: 'endDate', label: 'End', defaultSelected: true }, { key: 'status', label: 'Status', defaultSelected: true }, { key: 'createdAt', label: 'Created' }, ]; async function runTenanciesQuery({ portId, filter, }: { portId: string; columns: string[]; filter: CustomFilter; }): Promise>> { const conds = [ eq(tenancies.portId, portId), ...applyDateRange(tenancies.createdAt as never, filter), ]; const rows = await db .select({ clientName: clients.fullName, mooringNumber: berths.mooringNumber, tenureType: tenancies.tenureType, startDate: tenancies.startDate, endDate: tenancies.endDate, status: tenancies.status, createdAt: tenancies.createdAt, }) .from(tenancies) .leftJoin(clients, eq(tenancies.clientId, clients.id)) .leftJoin(berths, eq(tenancies.berthId, berths.id)) .where(and(...conds)) .orderBy(desc(tenancies.startDate)) .limit(10_000); return rows.map((r) => ({ ...r })); } // ─── Registry ──────────────────────────────────────────────────────────────── export const ENTITY_REGISTRY: Record = { clients: { key: 'clients', label: 'Clients', description: 'People in your CRM: name, source, contact preferences.', dateAxis: 'Created', columns: CLIENTS_COLUMNS, runQuery: runClientsQuery, }, interests: { key: 'interests', label: 'Interests / deals', description: 'Sales pipeline: stage, outcome, value, deposit details.', dateAxis: 'Created', columns: INTERESTS_COLUMNS, runQuery: runInterestsQuery, }, berths: { key: 'berths', label: 'Berths', description: 'Mooring inventory: dimensions, status, price.', dateAxis: 'Created', columns: BERTHS_COLUMNS, runQuery: runBerthsQuery, }, tenancies: { key: 'tenancies', label: 'Tenancies', description: 'Berth leases / annual contracts: dates, tenure type, status.', dateAxis: 'Created', columns: TENANCIES_COLUMNS, runQuery: runTenanciesQuery, }, };