/** * Audit log search — PR1 skeleton. PR10 fills in the cursor pagination * and per-port + super-admin scoping; v1 already has the GIN index on * `audit_logs.search_text`. */ import { and, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm'; import { db } from '@/lib/db'; import { auditLogs, type AuditLog } from '@/lib/db/schema/system'; export interface AuditSearchOptions { /** Limit results to a single port. Omit for super-admin all-ports view. */ portId?: string; /** Free-text query — runs against the GIN-indexed search_text column. */ q?: string; /** Filter by actor (user id). */ userId?: string; /** Filter by action verb: 'create' | 'update' | 'delete' | ... */ action?: string; /** Filter by entity type: 'client' | 'interest' | 'document' | ... */ entityType?: string; /** Filter by exact entity id (e.g. paste a uuid into search). */ entityId?: string; /** Inclusive date range. */ from?: Date; to?: Date; /** Pagination — cursor on (createdAt, id). */ cursor?: { createdAt: Date; id: string }; limit?: number; } export interface AuditSearchPage { rows: AuditLog[]; nextCursor: { createdAt: Date; id: string } | null; } export async function searchAuditLogs(options: AuditSearchOptions = {}): Promise { const conds: SQL[] = []; if (options.portId) conds.push(eq(auditLogs.portId, options.portId)); if (options.userId) conds.push(eq(auditLogs.userId, options.userId)); if (options.action) conds.push(eq(auditLogs.action, options.action)); if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType)); if (options.entityId) conds.push(eq(auditLogs.entityId, options.entityId)); if (options.from) conds.push(gte(auditLogs.createdAt, options.from)); if (options.to) conds.push(lte(auditLogs.createdAt, options.to)); if (options.q) { // tsquery match against the GENERATED tsvector column. conds.push(sql`${auditLogs.searchText} @@ plainto_tsquery('simple', ${options.q})`); } if (options.cursor) { // Strict less-than on (createdAt, id) for stable cursor pagination. // ISO-stringify the date so postgres-js binds it cleanly inside a tuple // comparison; raw Date objects throw under postgres@3.x parameter binding. const cursorAt = options.cursor.createdAt.toISOString(); conds.push( sql`(${auditLogs.createdAt}, ${auditLogs.id}) < (${cursorAt}::timestamptz, ${options.cursor.id})`, ); } const limit = Math.min(options.limit ?? 50, 200); const rows = await db.query.auditLogs.findMany({ where: conds.length > 0 ? and(...conds) : undefined, orderBy: [desc(auditLogs.createdAt), desc(auditLogs.id)], limit: limit + 1, }); const hasMore = rows.length > limit; const truncated = hasMore ? rows.slice(0, limit) : rows; const last = truncated[truncated.length - 1]; return { rows: truncated, nextCursor: hasMore && last ? { createdAt: last.createdAt, id: last.id } : null, }; }