import { Injectable } from '@nestjs/common'; import { BaseRepository } from '@edr/api-common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { AuditLog } from './entities/audit-log.entity'; import type { AuditReferenceSource } from './audit-reference.registry'; export interface AuditLogQuery { type?: string; userId?: string; method?: string; isSuccess?: boolean; resourceId?: string; reference?: string; userName?: string; title?: string; q?: string; from?: Date; to?: Date; skip: number; take: number; } @Injectable() export class AuditLogRepository extends BaseRepository { constructor( @InjectRepository(AuditLog) private readonly auditLogRepository: Repository, ) { super(auditLogRepository); } /** * Insert one audit row. * * `insert` rather than `save`: save would issue a SELECT first to decide * between insert and update, which is wasted work for a table that is only * ever appended to. */ async record(entry: Partial): Promise { await this.auditLogRepository.insert( entry as QueryDeepPartialEntity, ); } /** * Resolve the human identifier for one entity row (`WHERE id = $1`). * * `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never * from user input — so interpolating its table/column is safe; the id is * bound as a parameter. Returns null when the row doesn't exist or the * identifier column is empty. */ async lookupReference( source: AuditReferenceSource, id: string, ): Promise { const rows = await this.auditLogRepository.manager.query< { reference: string | null }[] >( `SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`, [id], ); return rows[0]?.reference || null; } /** * Paginated, filtered read. Newest first — every index on this table is * ordered `created_at DESC` to match. * * Query builder rather than `findAndCount`: `q` needs an OR across four * columns, and `reference` needs the `upper(...) LIKE` shape that matches * the expression index — neither fits `FindOptionsWhere`. */ async search(query: AuditLogQuery): Promise<[AuditLog[], number]> { const qb = this.auditLogRepository.createQueryBuilder('audit_log'); if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type }); if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId }); if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method }); if (query.resourceId) { qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId }); } if (query.isSuccess !== undefined) { qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess }); } // Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper. // The explicit <> '' repeats the index's partial predicate — without it the // planner cannot prove the partial index applies and falls back to a scan. if (query.reference) { qb.andWhere("audit_log.reference <> ''").andWhere( "upper(audit_log.reference) LIKE upper(:reference) || '%'", { reference: escapeLike(query.reference) }, ); } if (query.userName) { qb.andWhere('audit_log.user_name ILIKE :userName', { userName: `%${escapeLike(query.userName)}%`, }); } if (query.title) { qb.andWhere('audit_log.title ILIKE :title', { title: `%${escapeLike(query.title)}%`, }); } // One search box across the columns staff actually search by. // ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN // indexes if the table grows past a few million rows. if (query.q) { const q = `%${escapeLike(query.q)}%`; qb.andWhere( `(audit_log.reference ILIKE :q OR audit_log.resource_id ILIKE :q OR audit_log.user_name ILIKE :q OR audit_log.title ILIKE :q)`, { q }, ); } // Date range: either bound may be supplied alone. if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from }); if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to }); return qb .orderBy('audit_log.created_at', 'DESC') .skip(query.skip) .take(query.take) .getManyAndCount(); } /** Distinct entity types present, for populating a filter dropdown. */ async distinctTypes(): Promise { const rows = await this.auditLogRepository .createQueryBuilder('audit_log') .select('DISTINCT audit_log.type', 'type') .orderBy('audit_log.type', 'ASC') .getRawMany<{ type: string }>(); return rows.map((row) => row.type); } /** Distinct action titles present, for the action filter dropdown. */ async distinctTitles(): Promise { const rows = await this.auditLogRepository .createQueryBuilder('audit_log') .select('DISTINCT audit_log.title', 'title') .orderBy('audit_log.title', 'ASC') .getRawMany<{ title: string }>(); return rows.map((row) => row.title); } } /** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */ function escapeLike(value: string): string { return value.replace(/[\\%_]/g, (ch) => `\\${ch}`); }