Skip to content

Commit e369199

Browse files
msukkariclaude
andauthored
feat(web): Add pagination and time range filtering to audit endpoint (#949)
* feat(web): Add pagination and time range filtering to audit GET endpoint The audit API now supports pagination with page and perPage query parameters (max 100 items), and time range filtering with since and until parameters in UTC. Returns X-Total-Count header and RFC 5988 Link header for pagination, matching the commits API pattern. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * chore: Update CHANGELOG for audit pagination feature (#949) * chore: Shorten CHANGELOG entry * fix(web): validate inverted time ranges and add deterministic audit pagination Reject requests where 'since' >= 'until' with a 400 error, and add 'id' as a tiebreaker to the orderBy clause so paginated results are deterministic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 8e0e737 commit e369199

File tree

3 files changed

+94
-15
lines changed

3 files changed

+94
-15
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Added pagination and UTC time range filtering to the audit GET endpoint. [#949](https://github.com/sourcebot-dev/sourcebot/pull/949)
12+
1013
### Fixed
1114
- 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)
1215
- Fixed `content:` filter ignoring the regex toggle. [#947](https://github.com/sourcebot-dev/sourcebot/pull/947)

packages/web/src/app/api/(server)/ee/audit/route.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,27 @@
33
import { fetchAuditRecords } from "@/ee/features/audit/actions";
44
import { apiHandler } from "@/lib/apiHandler";
55
import { ErrorCode } from "@/lib/errorCodes";
6-
import { serviceErrorResponse } from "@/lib/serviceError";
6+
import { buildLinkHeader } from "@/lib/pagination";
7+
import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError";
78
import { isServiceError } from "@/lib/utils";
89
import { getEntitlements } from "@sourcebot/shared";
910
import { StatusCodes } from "http-status-codes";
11+
import { NextRequest } from "next/server";
12+
import { z } from "zod";
1013

11-
export const GET = apiHandler(async () => {
14+
const auditQueryParamsBaseSchema = z.object({
15+
since: z.string().datetime().optional(),
16+
until: z.string().datetime().optional(),
17+
page: z.coerce.number().int().positive().default(1),
18+
perPage: z.coerce.number().int().positive().max(100).default(50),
19+
});
20+
21+
const auditQueryParamsSchema = auditQueryParamsBaseSchema.refine(
22+
(data) => !(data.since && data.until && new Date(data.since) >= new Date(data.until)),
23+
{ message: "'since' must be before 'until'", path: ["since"] }
24+
);
25+
26+
export const GET = apiHandler(async (request: NextRequest) => {
1227
const entitlements = getEntitlements();
1328
if (!entitlements.includes('audit')) {
1429
return serviceErrorResponse({
@@ -18,9 +33,49 @@ export const GET = apiHandler(async () => {
1833
});
1934
}
2035

21-
const result = await fetchAuditRecords();
36+
const rawParams = Object.fromEntries(
37+
Object.keys(auditQueryParamsBaseSchema.shape).map(key => [
38+
key,
39+
request.nextUrl.searchParams.get(key) ?? undefined
40+
])
41+
);
42+
const parsed = auditQueryParamsSchema.safeParse(rawParams);
43+
44+
if (!parsed.success) {
45+
return serviceErrorResponse(
46+
queryParamsSchemaValidationError(parsed.error)
47+
);
48+
}
49+
50+
const { page, perPage, since, until } = parsed.data;
51+
const skip = (page - 1) * perPage;
52+
53+
const result = await fetchAuditRecords({
54+
skip,
55+
take: perPage,
56+
since: since ? new Date(since) : undefined,
57+
until: until ? new Date(until) : undefined,
58+
});
59+
2260
if (isServiceError(result)) {
2361
return serviceErrorResponse(result);
2462
}
25-
return Response.json(result);
26-
});
63+
64+
const { auditRecords, totalCount } = result;
65+
66+
const headers = new Headers({ 'Content-Type': 'application/json' });
67+
headers.set('X-Total-Count', totalCount.toString());
68+
69+
const linkHeader = buildLinkHeader(request, {
70+
page,
71+
perPage,
72+
totalCount,
73+
extraParams: {
74+
...(since ? { since } : {}),
75+
...(until ? { until } : {}),
76+
},
77+
});
78+
if (linkHeader) headers.set('Link', linkHeader);
79+
80+
return new Response(JSON.stringify(auditRecords), { status: 200, headers });
81+
});

packages/web/src/ee/features/audit/actions.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,39 @@ export const createAuditAction = async (event: Omit<AuditEvent, 'sourcebotVersio
2525
})
2626
);
2727

28-
export const fetchAuditRecords = async () => sew(() =>
28+
export interface FetchAuditRecordsParams {
29+
skip: number;
30+
take: number;
31+
since?: Date;
32+
until?: Date;
33+
}
34+
35+
export const fetchAuditRecords = async (params: FetchAuditRecordsParams) => sew(() =>
2936
withAuthV2(async ({ user, org, role }) =>
3037
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
3138
try {
32-
const auditRecords = await prisma.audit.findMany({
33-
where: {
34-
orgId: org.id,
35-
},
36-
orderBy: {
37-
timestamp: 'desc'
38-
}
39-
});
39+
const where = {
40+
orgId: org.id,
41+
...(params.since || params.until ? {
42+
timestamp: {
43+
...(params.since ? { gte: params.since } : {}),
44+
...(params.until ? { lte: params.until } : {}),
45+
}
46+
} : {}),
47+
};
48+
49+
const [auditRecords, totalCount] = await Promise.all([
50+
prisma.audit.findMany({
51+
where,
52+
orderBy: [
53+
{ timestamp: 'desc' },
54+
{ id: 'desc' },
55+
],
56+
skip: params.skip,
57+
take: params.take,
58+
}),
59+
prisma.audit.count({ where }),
60+
]);
4061

4162
await auditService.createAudit({
4263
action: "audit.fetch",
@@ -51,7 +72,7 @@ export const fetchAuditRecords = async () => sew(() =>
5172
orgId: org.id
5273
})
5374

54-
return auditRecords;
75+
return { auditRecords, totalCount };
5576
} catch (error) {
5677
logger.error('Error fetching audit logs', { error });
5778
return {

0 commit comments

Comments
 (0)