Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
3.5 kB ยท 111 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112import { type SQL, and, desc, eq, gte, isNull, sql } from "@openstatus/db";import { auditLog, selectAuditLogSchema, user,} from "@openstatus/db/src/schema";import type { z } from "zod";
import { type ServiceContext, getReadDb } from "../context";import { ListAuditLogsInput } from "./schemas";
/** * Fixed read window for the dashboard audit-log viewer. * * Keeps payloads bounded without a configurable limit, and caps what a * leaked/compromised session can exfiltrate from the table. Retention * policy is a separate lever (PR 3+); this is the *read* cap. */const READ_WINDOW_DAYS = 14;
export type AuditLogListItem = z.infer<typeof selectAuditLogSchema> & { user: { id: number; name: string | null; email: string | null; photoUrl: string | null; } | null;};
export type ListAuditLogsResult = { items: AuditLogListItem[]; totalSize: number;};
/** * List audit-log rows for the caller's workspace, optionally scoped * to a single entity. Bounded to the last `READ_WINDOW_DAYS`. * * `audit_log` has no FK to `user` (rows outlive their subjects), so we * use a left join โ rows whose `actorUserId` is null (system / webhook * / unlinked api-key actors) or whose user has been soft-deleted still * come back with `user: null`. * * Sort is `(createdAt DESC, id DESC)` โ bulk operations emit multiple * rows in the same millisecond; the autoincrement id gives a * deterministic tiebreaker. */export async function listAuditLogs(args: { ctx: ServiceContext; input?: ListAuditLogsInput;}): Promise<ListAuditLogsResult> { const { ctx } = args; const input = ListAuditLogsInput.parse(args.input); const db = getReadDb(ctx);
const since = new Date(Date.now() - READ_WINDOW_DAYS * 24 * 60 * 60 * 1000);
const conditions: SQL[] = [ eq(auditLog.workspaceId, ctx.workspace.id), gte(auditLog.createdAt, since), ]; if (input.entityType) { conditions.push(eq(auditLog.entityType, input.entityType)); } if (input.entityId) { conditions.push(eq(auditLog.entityId, input.entityId)); } const whereClause = and(...conditions);
// Count and page queries run in parallel outside a transaction โ a // concurrent insert between them can leave `totalSize` one off from the // returned page. Best-effort is fine for list pagination. const [countRow, rows] = await Promise.all([ db .select({ count: sql<number>`count(*)` }) .from(auditLog) .where(whereClause) .get(), db .select({ auditLog, user: { id: user.id, name: user.name, email: user.email, photoUrl: user.photoUrl, }, }) .from(auditLog) // Narrow the join so soft-deleted users don't surface as actors. // `deleteAccount` preserves the row (deletedAt + PII wipe), which // a bare `eq(user.id, actorUserId)` would still match, returning a // user object with blanked fields instead of `user: null`. Readers // can't distinguish that from a real actor without the `isNull`. .leftJoin( user, and(eq(user.id, auditLog.actorUserId), isNull(user.deletedAt)), ) .where(whereClause) .orderBy(desc(auditLog.createdAt), desc(auditLog.id)) .limit(input.limit) .offset(input.offset) .all(), ]);
const items = rows.map((row) => ({ ...selectAuditLogSchema.parse(row.auditLog), user: row.user?.id != null ? row.user : null, })); return { items, totalSize: countRow?.count ?? 0 };}