All files / src/server/repositories/implementations AuditLogRepository.ts

100% Statements 9/9
100% Branches 6/6
100% Functions 5/5
100% Lines 8/8

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64                      91x 91x           31x 31x             67x     62x                               21x           22x                        
import { eq, desc } from "drizzle-orm";
import type { Database } from "@/lib/db";
import { auditLogTable } from "@/lib/db/schema";
import { generateId } from "@/lib/id";
import type {
  AuditAction,
  AuditLog,
  AuditTargetType,
  NewAuditLog,
} from "@/types";
 
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 200;
 
// Always clamp: a hostile/absent ?limit= must never produce an unbounded scan.
// Exported for a pure-unit test that pins the cap constants (the DB tests only
// prove the result is bounded, not that the ceiling is exactly 200).
export function clampLimit(raw: number | undefined): number {
  const n = Number(raw);
  return Number.isFinite(n) ? Math.min(MAX_LIMIT, Math.max(1, Math.trunc(n))) : DEFAULT_LIMIT;
}
 
// Constructed on whatever Database it's given: the injected db for reads, a tx
// for the atomic purge write. No optional-tx overload — a non-tx write must be
// impossible to express (see ItemPurgeService).
export class AuditLogRepository {
  constructor(private db: Database) {}
 
  async record(entry: NewAuditLog): Promise<void> {
    await this.db.insert(auditLogTable).values({
      id: generateId("audit"),
      actorId: entry.actorId,
      action: entry.action,
      targetType: entry.targetType,
      targetId: entry.targetId,
      collectionId: entry.collectionId,
      metadata: entry.metadata ?? null,
    });
  }
 
  // Newest-first, total order (createdAt then id) so same-ms ties are stable.
  async findByCollection(
    collectionId: string,
    opts?: { limit?: number },
  ): Promise<AuditLog[]> {
    const rows = await this.db
      .select()
      .from(auditLogTable)
      .where(eq(auditLogTable.collectionId, collectionId))
      .orderBy(desc(auditLogTable.createdAt), desc(auditLogTable.id))
      .limit(clampLimit(opts?.limit));
    return rows.map((row) => ({
      id: row.id,
      actorId: row.actorId,
      action: row.action as AuditAction,
      targetType: row.targetType as AuditTargetType,
      targetId: row.targetId,
      collectionId: row.collectionId,
      metadata: (row.metadata as Record<string, number> | null) ?? null,
      createdAt: row.createdAt,
    }));
  }
}