Task #95: Let admins export the audit log as CSV
What was done - Added a new admin-only endpoint GET /api/admin/audit-logs/export that streams the filtered audit log as a CSV download. Honors the same `action`, `from`, and `to` query parameters as the list endpoint, validated through a shared parseFilters/buildWhere helper extracted from the existing handler. - Columns: action, actor_username, target_type, target_id, created_at, metadata (compact JSON). Rows ordered newest-first and capped at 50,000 to bound response size. UTF-8 BOM prepended so spreadsheet apps (incl. Excel) detect encoding correctly for Arabic content stored in metadata. - Response sets Content-Type: text/csv; charset=utf-8, Content-Disposition: attachment; filename="audit-log-YYYY-MM-DD.csv", Cache-Control: no-store, and is written via res.write streaming. - Updated lib/api-spec/openapi.yaml with operationId exportAuditLogsCsv and ran codegen to regenerate the React client + Zod schemas. - Frontend (artifacts/tx-os/src/pages/admin.tsx → AuditLogPanel): added an "Export CSV" button next to the existing filter actions. Clicking it fetches the export URL with current filters using same-origin cookies, triggers a browser download, and surfaces a localized error if the request fails. The button is disabled while filters are invalid or while a download is in flight, and a spinner replaces the icon during export. - Added bilingual translation keys admin.audit.export.button / admin.audit.export.failed in en.json and ar.json. Security hardening (post code-review) - csvEscape now prefixes a single quote when a cell value begins with one of =, +, -, @, tab, or CR, mitigating CSV/spreadsheet formula injection (Excel, LibreOffice, Google Sheets evaluate such cells as formulas). Verified end-to-end by inserting a synthetic audit row whose action started with "=" and confirming the export wrote it as "'=...". Verification - Typecheck of tx-os passes; api-server has only pre-existing executive-meetings errors unrelated to this change. - Manual curl smoke test: 200 with correct headers, BOM present, action and date filters honored, invalid date returns 400. - E2e UI test (Playwright via testing skill): logged in as admin, opened the Audit Log section, downloaded the unfiltered CSV (correct filename and header row), then re-exported with action=app.delete and confirmed only matching rows came back. - Targeted security check confirmed CSV injection mitigation (see above). Notes / drift - None for the spec. The generated `exportAuditLogsCsv()` helper in lib/api-client-react is typed Promise<Blob> but, because customFetch auto-infers text/csv as text, would currently return text if anyone called it directly. The Audit Log UI uses a raw fetch with credentials: "include" to download the blob, so this does not affect the feature. Updating the generated client's responseType handling is project-wide config and is intentionally out of scope here. Follow-ups proposed - #123 Add automated tests for the audit log CSV export (test_gaps) - #124 Let admins pick which columns to include when exporting the audit log (next_steps) Replit-Task-Id: 88a50100-caa7-4b37-b9da-cfdc42bee119
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { Router, type IRouter, type Request, type Response } from "express";
|
||||
import { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import { auditLogsTable, usersTable } from "@workspace/db";
|
||||
@@ -7,6 +7,7 @@ import { requireAdmin } from "../middlewares/auth";
|
||||
const router: IRouter = Router();
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const EXPORT_ROW_CAP = 50_000;
|
||||
|
||||
function parseUtcDate(s: string): Date | null {
|
||||
if (!DATE_RE.test(s)) return null;
|
||||
@@ -14,7 +15,13 @@ function parseUtcDate(s: string): Date | null {
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> => {
|
||||
type AuditFilters = {
|
||||
action: string;
|
||||
fromDate: Date | null;
|
||||
toDateExclusive: Date | null;
|
||||
};
|
||||
|
||||
function parseFilters(req: Request, res: Response): AuditFilters | null {
|
||||
const action = typeof req.query.action === "string" ? req.query.action.trim() : "";
|
||||
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
|
||||
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
|
||||
@@ -25,21 +32,49 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> =>
|
||||
fromDate = parseUtcDate(fromStr);
|
||||
if (!fromDate) {
|
||||
res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (toStr) {
|
||||
const toDate = parseUtcDate(toStr);
|
||||
if (!toDate) {
|
||||
res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
if (fromDate && toDateExclusive && fromDate >= toDateExclusive) {
|
||||
res.status(400).json({ error: "'from' must be on or before 'to'" });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
return { action, fromDate, toDateExclusive };
|
||||
}
|
||||
|
||||
function buildWhere(filters: AuditFilters): SQL | undefined {
|
||||
const conditions: SQL[] = [];
|
||||
if (filters.action) conditions.push(eq(auditLogsTable.action, filters.action));
|
||||
if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate));
|
||||
if (filters.toDateExclusive)
|
||||
conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive));
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
}
|
||||
|
||||
function csvEscape(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
let s = typeof value === "string" ? value : String(value);
|
||||
// Mitigate CSV/spreadsheet formula injection (Excel, LibreOffice, Google
|
||||
// Sheets evaluate cells starting with =, +, -, @, or a tab/CR as formulas).
|
||||
// Prefix a single quote so the spreadsheet renders the value as text.
|
||||
if (s.length > 0 && /^[=+\-@\t\r]/.test(s)) {
|
||||
s = `'${s}`;
|
||||
}
|
||||
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
}
|
||||
|
||||
router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> => {
|
||||
const filters = parseFilters(req, res);
|
||||
if (!filters) return;
|
||||
|
||||
const limitRaw = Number(req.query.limit);
|
||||
const offsetRaw = Number(req.query.offset);
|
||||
@@ -50,11 +85,7 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> =>
|
||||
const offset =
|
||||
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
if (action) conditions.push(eq(auditLogsTable.action, action));
|
||||
if (fromDate) conditions.push(gte(auditLogsTable.createdAt, fromDate));
|
||||
if (toDateExclusive) conditions.push(lt(auditLogsTable.createdAt, toDateExclusive));
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const whereClause = buildWhere(filters);
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
@@ -121,4 +152,66 @@ router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> =>
|
||||
});
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/admin/audit-logs/export",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const filters = parseFilters(req, res);
|
||||
if (!filters) return;
|
||||
|
||||
const whereClause = buildWhere(filters);
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
action: auditLogsTable.action,
|
||||
targetType: auditLogsTable.targetType,
|
||||
targetId: auditLogsTable.targetId,
|
||||
metadata: auditLogsTable.metadata,
|
||||
createdAt: auditLogsTable.createdAt,
|
||||
actorUsername: usersTable.username,
|
||||
})
|
||||
.from(auditLogsTable)
|
||||
.leftJoin(usersTable, eq(usersTable.id, auditLogsTable.actorUserId));
|
||||
|
||||
const rows = await (whereClause ? baseQuery.where(whereClause) : baseQuery)
|
||||
.orderBy(desc(auditLogsTable.createdAt), desc(auditLogsTable.id))
|
||||
.limit(EXPORT_ROW_CAP);
|
||||
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const filename = `audit-log-${stamp}.csv`;
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${filename}"`,
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
// UTF-8 BOM so spreadsheet apps (Excel) detect encoding correctly,
|
||||
// important for Arabic content stored in metadata.
|
||||
res.write("\uFEFF");
|
||||
res.write("action,actor_username,target_type,target_id,created_at,metadata\r\n");
|
||||
|
||||
for (const r of rows) {
|
||||
const createdAtIso =
|
||||
r.createdAt instanceof Date
|
||||
? r.createdAt.toISOString()
|
||||
: new Date(r.createdAt as unknown as string).toISOString();
|
||||
const metadataJson =
|
||||
r.metadata == null ? "" : JSON.stringify(r.metadata);
|
||||
const line = [
|
||||
csvEscape(r.action),
|
||||
csvEscape(r.actorUsername),
|
||||
csvEscape(r.targetType),
|
||||
csvEscape(r.targetId),
|
||||
csvEscape(createdAtIso),
|
||||
csvEscape(metadataJson),
|
||||
].join(",");
|
||||
res.write(line);
|
||||
res.write("\r\n");
|
||||
}
|
||||
|
||||
res.end();
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -489,6 +489,10 @@
|
||||
"showing": "عرض {{shown}} من أصل {{total}}",
|
||||
"empty": "لا توجد إدخالات في سجل التدقيق تطابق الفلاتر.",
|
||||
"loadMore": "عرض المزيد",
|
||||
"export": {
|
||||
"button": "تصدير CSV",
|
||||
"failed": "تعذّر تصدير سجل التدقيق. يُرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"errors": {
|
||||
"invalidDate": "يجب أن تكون التواريخ بصيغة YYYY-MM-DD.",
|
||||
"invertedDates": "تاريخ \"من\" يجب أن يكون قبل أو يساوي تاريخ \"إلى\".",
|
||||
|
||||
@@ -486,6 +486,10 @@
|
||||
"showing": "Showing {{shown}} of {{total}}",
|
||||
"empty": "No audit log entries match these filters.",
|
||||
"loadMore": "Load more",
|
||||
"export": {
|
||||
"button": "Export CSV",
|
||||
"failed": "Could not export the audit log. Please try again."
|
||||
},
|
||||
"errors": {
|
||||
"invalidDate": "Dates must be in YYYY-MM-DD format.",
|
||||
"invertedDates": "\"From\" date must be on or before \"To\" date.",
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
useReplaceRolePermissions,
|
||||
useListAuditLogs,
|
||||
getListAuditLogsQueryKey,
|
||||
getExportAuditLogsCsvUrl,
|
||||
ApiError,
|
||||
} from "@workspace/api-client-react";
|
||||
import type {
|
||||
@@ -74,7 +75,7 @@ import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText } from "lucide-react";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -4001,6 +4002,8 @@ function AuditLogPanel() {
|
||||
const [appliedFrom, setAppliedFrom] = useState<string>("");
|
||||
const [appliedTo, setAppliedTo] = useState<string>("");
|
||||
const [pageLimit, setPageLimit] = useState<number>(50);
|
||||
const [exporting, setExporting] = useState<boolean>(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
const fromValid = !appliedFrom || DATE_RE.test(appliedFrom);
|
||||
const toValid = !appliedTo || DATE_RE.test(appliedTo);
|
||||
@@ -4048,6 +4051,45 @@ function AuditLogPanel() {
|
||||
|
||||
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
|
||||
|
||||
const handleExportCsv = async () => {
|
||||
if (!filtersValid || exporting) return;
|
||||
setExportError(null);
|
||||
setExporting(true);
|
||||
try {
|
||||
const url = getExportAuditLogsCsvUrl({
|
||||
...(actionFilter ? { action: actionFilter } : {}),
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
});
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: { Accept: "text/csv" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const filename = `audit-log-${stamp}.csv`;
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Defer revoke so the browser can finish the download.
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
} catch (err) {
|
||||
setExportError(
|
||||
err instanceof Error ? err.message : t("admin.audit.export.failed"),
|
||||
);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="audit-log-panel">
|
||||
<div className="glass-panel rounded-2xl p-3 space-y-3">
|
||||
@@ -4107,11 +4149,33 @@ function AuditLogPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleExportCsv}
|
||||
disabled={!filtersValid || exporting}
|
||||
className="gap-1.5"
|
||||
data-testid="audit-export-csv"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t("admin.audit.export.button")}
|
||||
</Button>
|
||||
</div>
|
||||
{inputErrors.map((msg, i) => (
|
||||
<p key={i} className="text-xs text-destructive">
|
||||
{msg}
|
||||
</p>
|
||||
))}
|
||||
{exportError && (
|
||||
<p className="text-xs text-destructive" data-testid="audit-export-error">
|
||||
{t("admin.audit.export.failed")}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{filtersValid
|
||||
? t("admin.audit.showing", {
|
||||
|
||||
@@ -999,3 +999,18 @@ export type ListAuditLogsParams = {
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ExportAuditLogsCsvParams = {
|
||||
/**
|
||||
* Exact action name to filter by (e.g. "group.force_delete").
|
||||
*/
|
||||
action?: string;
|
||||
/**
|
||||
* Start date (inclusive, YYYY-MM-DD UTC).
|
||||
*/
|
||||
from?: string;
|
||||
/**
|
||||
* End date (inclusive, YYYY-MM-DD UTC).
|
||||
*/
|
||||
to?: string;
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
DeleteServiceParams,
|
||||
DeleteUserParams,
|
||||
ErrorResponse,
|
||||
ExportAuditLogsCsvParams,
|
||||
ForgotPasswordBody,
|
||||
ForgotPasswordResponse,
|
||||
GetAdminAppOpensByAppParams,
|
||||
@@ -6261,3 +6262,105 @@ export function useListAuditLogs<
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the filtered audit log as a CSV download.
|
||||
Honors the same `action`, `from`, and `to` filters as the list endpoint.
|
||||
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||
Capped at 50,000 rows per export to bound response size.
|
||||
|
||||
* @summary Export filtered audit log as CSV (admin)
|
||||
*/
|
||||
export const getExportAuditLogsCsvUrl = (params?: ExportAuditLogsCsvParams) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/admin/audit-logs/export?${stringifiedParams}`
|
||||
: `/api/admin/audit-logs/export`;
|
||||
};
|
||||
|
||||
export const exportAuditLogsCsv = async (
|
||||
params?: ExportAuditLogsCsvParams,
|
||||
options?: RequestInit,
|
||||
): Promise<Blob> => {
|
||||
return customFetch<Blob>(getExportAuditLogsCsvUrl(params), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getExportAuditLogsCsvQueryKey = (
|
||||
params?: ExportAuditLogsCsvParams,
|
||||
) => {
|
||||
return [`/api/admin/audit-logs/export`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getExportAuditLogsCsvQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof exportAuditLogsCsv>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
params?: ExportAuditLogsCsvParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getExportAuditLogsCsvQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof exportAuditLogsCsv>>
|
||||
> = ({ signal }) => exportAuditLogsCsv(params, { signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ExportAuditLogsCsvQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof exportAuditLogsCsv>>
|
||||
>;
|
||||
export type ExportAuditLogsCsvQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Export filtered audit log as CSV (admin)
|
||||
*/
|
||||
|
||||
export function useExportAuditLogsCsv<
|
||||
TData = Awaited<ReturnType<typeof exportAuditLogsCsv>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
params?: ExportAuditLogsCsvParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof exportAuditLogsCsv>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getExportAuditLogsCsvQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
@@ -1854,6 +1854,52 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/admin/audit-logs/export:
|
||||
get:
|
||||
operationId: exportAuditLogsCsv
|
||||
tags: [audit]
|
||||
summary: Export filtered audit log as CSV (admin)
|
||||
description: |
|
||||
Streams the filtered audit log as a CSV download.
|
||||
Honors the same `action`, `from`, and `to` filters as the list endpoint.
|
||||
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||
Capped at 50,000 rows per export to bound response size.
|
||||
parameters:
|
||||
- in: query
|
||||
name: action
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Exact action name to filter by (e.g. "group.force_delete").
|
||||
- in: query
|
||||
name: from
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: Start date (inclusive, YYYY-MM-DD UTC).
|
||||
- in: query
|
||||
name: to
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
description: End date (inclusive, YYYY-MM-DD UTC).
|
||||
responses:
|
||||
"200":
|
||||
description: CSV file download
|
||||
content:
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"400":
|
||||
description: Invalid filter parameters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HealthStatus:
|
||||
|
||||
@@ -2098,3 +2098,23 @@ export const ListAuditLogsResponse = zod.object({
|
||||
"All distinct action names present in the audit log (for building filter UI).",
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Streams the filtered audit log as a CSV download.
|
||||
Honors the same `action`, `from`, and `to` filters as the list endpoint.
|
||||
Columns: action, actor_username, target_type, target_id, created_at, metadata.
|
||||
Capped at 50,000 rows per export to bound response size.
|
||||
|
||||
* @summary Export filtered audit log as CSV (admin)
|
||||
*/
|
||||
export const ExportAuditLogsCsvQueryParams = zod.object({
|
||||
action: zod.coerce
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Exact action name to filter by (e.g. \"group.force_delete\").'),
|
||||
from: zod
|
||||
.date()
|
||||
.optional()
|
||||
.describe("Start date (inclusive, YYYY-MM-DD UTC)."),
|
||||
to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user