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'; export interface BuildListQueryOptions { table: PgTable; portIdColumn: PgColumn; portId: string; idColumn: PgColumn; 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[]; searchTerm?: string; includeArchived?: boolean; archivedAtColumn?: PgColumn; } export interface ListResult { data: T[]; total: number; } /** * Generic Drizzle paginated query builder with port-scoping. * * - Port scoping is always the first condition in the AND chain. * - `archivedAt IS NULL` by default (unless `includeArchived` is true). * - Deterministic secondary sort: `updatedAt DESC, id DESC`. */ export async function buildListQuery(opts: BuildListQueryOptions): Promise> { const { table, portIdColumn, portId, idColumn, updatedAtColumn, filters = [], sort, customOrderBy, page, pageSize, searchColumns = [], searchTerm, includeArchived = false, archivedAtColumn, } = opts; const conditions: SQL[] = [eq(portIdColumn, portId)]; // Exclude archived by default if (!includeArchived && archivedAtColumn) { conditions.push(isNull(archivedAtColumn)); } // Full-text search across multiple columns via ILIKE if (searchTerm && searchColumns.length > 0) { const searchConditions = searchColumns.map((col) => ilike(col, `%${searchTerm}%`)); conditions.push(or(...searchConditions)!); } // Append caller-supplied filters conditions.push(...filters); const where = and(...conditions); // Count total const countResult = await db .select({ count: sql`count(*)::int` }) .from(table) .where(where); const total = countResult[0]?.count ?? 0; // Build order by: customOrderBy (if provided) wins over the default // column-based sort. Deterministic secondary sort always trails. const orderClauses: SQL[] = []; 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)); // Fetch page const offset = (page - 1) * pageSize; const data = await db .select() .from(table) .where(where) .orderBy(...orderClauses) .limit(pageSize) .offset(offset); return { data: data as T[], total }; }