/** * Custom-report entity registry — server query layer. * * 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. * * This module is SERVER-ONLY: it pulls in `@/lib/db` + drizzle to run * the underlying queries. The client-safe metadata (entity keys, * column allowlists, labels/descriptions, the filter/column type * contracts) lives in `registry-meta.ts` and is imported here. Client * components (e.g. the column-picker UI) MUST import from * `registry-meta.ts`, never this file, or they drag the DB layer into * the browser bundle. * * Each entity defines: * - `columns`: an allowlist of column keys + human labels (sourced * from `registry-meta.ts`). The allowlist 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 (in registry-meta.ts). * 2. Add its column array + ENTITY_META entry in registry-meta.ts. * 3. Add the matching `runQuery` + ENTITY_REGISTRY entry here. * 4. The UI's entity-picker reads ENTITY_META 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'; import { ENTITY_KEYS, ENTITY_META, type ColumnDefinition, type CustomEntityMeta, type CustomFilter, type EntityKey, } from './registry-meta'; // Re-export the client-safe contracts so existing SERVER imports of // this module keep working unchanged. export { ENTITY_KEYS }; export type { ColumnDefinition, CustomFilter, EntityKey }; /** * Full server-side entity definition: the client-safe metadata plus * the server-only `runQuery`. */ export interface CustomEntityDefinition extends CustomEntityMeta { /** 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 ───────────────────────────────────────────────────────────────── 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 ─────────────────────────────────────────────────────────────── 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 ────────────────────────────────────────────────────────────────── 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 ─────────────────────────────────────────────────────────────── 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 ──────────────────────────────────────────────────────────────── const RUN_QUERIES: Record = { clients: runClientsQuery, interests: runInterestsQuery, berths: runBerthsQuery, tenancies: runTenanciesQuery, }; /** * Full server registry: client-safe metadata (from `registry-meta.ts`) * composed with the matching `runQuery` for each entity. */ export const ENTITY_REGISTRY: Record = ENTITY_KEYS.reduce( (acc, key) => { acc[key] = { ...ENTITY_META[key], runQuery: RUN_QUERIES[key] }; return acc; }, {} as Record, );