diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a682f50..9fa7e3ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added pagination and UTC time range filtering to the audit GET endpoint. [#949](https://github.com/sourcebot-dev/sourcebot/pull/949) + ### Fixed - Fixed search query parser rejecting parenthesized regex alternation in filter values (e.g. `file:(test|spec)`, `-file:(test|spec)`). [#946](https://github.com/sourcebot-dev/sourcebot/pull/946) - Fixed `content:` filter ignoring the regex toggle. [#947](https://github.com/sourcebot-dev/sourcebot/pull/947) diff --git a/packages/web/src/app/api/(server)/ee/audit/route.ts b/packages/web/src/app/api/(server)/ee/audit/route.ts index d5dec2e0b..57491bf79 100644 --- a/packages/web/src/app/api/(server)/ee/audit/route.ts +++ b/packages/web/src/app/api/(server)/ee/audit/route.ts @@ -3,12 +3,27 @@ import { fetchAuditRecords } from "@/ee/features/audit/actions"; import { apiHandler } from "@/lib/apiHandler"; import { ErrorCode } from "@/lib/errorCodes"; -import { serviceErrorResponse } from "@/lib/serviceError"; +import { buildLinkHeader } from "@/lib/pagination"; +import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { getEntitlements } from "@sourcebot/shared"; import { StatusCodes } from "http-status-codes"; +import { NextRequest } from "next/server"; +import { z } from "zod"; -export const GET = apiHandler(async () => { +const auditQueryParamsBaseSchema = z.object({ + since: z.string().datetime().optional(), + until: z.string().datetime().optional(), + page: z.coerce.number().int().positive().default(1), + perPage: z.coerce.number().int().positive().max(100).default(50), +}); + +const auditQueryParamsSchema = auditQueryParamsBaseSchema.refine( + (data) => !(data.since && data.until && new Date(data.since) >= new Date(data.until)), + { message: "'since' must be before 'until'", path: ["since"] } +); + +export const GET = apiHandler(async (request: NextRequest) => { const entitlements = getEntitlements(); if (!entitlements.includes('audit')) { return serviceErrorResponse({ @@ -18,9 +33,49 @@ export const GET = apiHandler(async () => { }); } - const result = await fetchAuditRecords(); + const rawParams = Object.fromEntries( + Object.keys(auditQueryParamsBaseSchema.shape).map(key => [ + key, + request.nextUrl.searchParams.get(key) ?? undefined + ]) + ); + const parsed = auditQueryParamsSchema.safeParse(rawParams); + + if (!parsed.success) { + return serviceErrorResponse( + queryParamsSchemaValidationError(parsed.error) + ); + } + + const { page, perPage, since, until } = parsed.data; + const skip = (page - 1) * perPage; + + const result = await fetchAuditRecords({ + skip, + take: perPage, + since: since ? new Date(since) : undefined, + until: until ? new Date(until) : undefined, + }); + if (isServiceError(result)) { return serviceErrorResponse(result); } - return Response.json(result); -}); \ No newline at end of file + + const { auditRecords, totalCount } = result; + + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('X-Total-Count', totalCount.toString()); + + const linkHeader = buildLinkHeader(request, { + page, + perPage, + totalCount, + extraParams: { + ...(since ? { since } : {}), + ...(until ? { until } : {}), + }, + }); + if (linkHeader) headers.set('Link', linkHeader); + + return new Response(JSON.stringify(auditRecords), { status: 200, headers }); +}); diff --git a/packages/web/src/ee/features/audit/actions.ts b/packages/web/src/ee/features/audit/actions.ts index d533c3c2b..4e594d8f4 100644 --- a/packages/web/src/ee/features/audit/actions.ts +++ b/packages/web/src/ee/features/audit/actions.ts @@ -25,18 +25,39 @@ export const createAuditAction = async (event: Omit sew(() => +export interface FetchAuditRecordsParams { + skip: number; + take: number; + since?: Date; + until?: Date; +} + +export const fetchAuditRecords = async (params: FetchAuditRecordsParams) => sew(() => withAuthV2(async ({ user, org, role }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { try { - const auditRecords = await prisma.audit.findMany({ - where: { - orgId: org.id, - }, - orderBy: { - timestamp: 'desc' - } - }); + const where = { + orgId: org.id, + ...(params.since || params.until ? { + timestamp: { + ...(params.since ? { gte: params.since } : {}), + ...(params.until ? { lte: params.until } : {}), + } + } : {}), + }; + + const [auditRecords, totalCount] = await Promise.all([ + prisma.audit.findMany({ + where, + orderBy: [ + { timestamp: 'desc' }, + { id: 'desc' }, + ], + skip: params.skip, + take: params.take, + }), + prisma.audit.count({ where }), + ]); await auditService.createAudit({ action: "audit.fetch", @@ -51,7 +72,7 @@ export const fetchAuditRecords = async () => sew(() => orgId: org.id }) - return auditRecords; + return { auditRecords, totalCount }; } catch (error) { logger.error('Error fetching audit logs', { error }); return {