Git commit prior to merge

This commit is contained in:
riyadhafraa
2026-04-29 10:32:54 +00:00
parent ed52b845f1
commit 896f690ac0
9 changed files with 438 additions and 14 deletions
+36 -3
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter, type Request, type Response } 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 { and, desc, eq, gte, inArray, lt, or, sql, type SQL } from "drizzle-orm";
import { db } from "@workspace/db"; import { db } from "@workspace/db";
import { auditLogsTable, usersTable } from "@workspace/db"; import { auditLogsTable, usersTable } from "@workspace/db";
import { requireAdmin } from "../middlewares/auth"; import { requireAdmin } from "../middlewares/auth";
@@ -9,6 +9,22 @@ const router: IRouter = Router();
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const EXPORT_ROW_CAP = 50_000; const EXPORT_ROW_CAP = 50_000;
// Actions that explicitly mark a forced deletion. Both the dedicated
// `*.force_delete` action names and the legacy `*.delete` rows whose metadata
// carries `force: true` count as forced deletions for the audit-log filter.
const FORCE_DELETE_ACTIONS = [
"group.force_delete",
"user.force_delete",
"app.force_delete",
"service.force_delete",
] as const;
const SOFT_DELETE_ACTIONS_WITH_FORCE_FLAG = [
"group.delete",
"user.delete",
"app.delete",
] as const;
function parseUtcDate(s: string): Date | null { function parseUtcDate(s: string): Date | null {
if (!DATE_RE.test(s)) return null; if (!DATE_RE.test(s)) return null;
const d = new Date(`${s}T00:00:00.000Z`); const d = new Date(`${s}T00:00:00.000Z`);
@@ -17,12 +33,16 @@ function parseUtcDate(s: string): Date | null {
type AuditFilters = { type AuditFilters = {
action: string; action: string;
forcedOnly: boolean;
fromDate: Date | null; fromDate: Date | null;
toDateExclusive: Date | null; toDateExclusive: Date | null;
}; };
function parseFilters(req: Request, res: Response): AuditFilters | null { function parseFilters(req: Request, res: Response): AuditFilters | null {
const action = typeof req.query.action === "string" ? req.query.action.trim() : ""; const action = typeof req.query.action === "string" ? req.query.action.trim() : "";
const forcedOnlyRaw =
typeof req.query.forcedOnly === "string" ? req.query.forcedOnly.trim() : "";
const forcedOnly = forcedOnlyRaw === "true" || forcedOnlyRaw === "1";
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : ""; const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : ""; const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
@@ -47,12 +67,25 @@ function parseFilters(req: Request, res: Response): AuditFilters | null {
res.status(400).json({ error: "'from' must be on or before 'to'" }); res.status(400).json({ error: "'from' must be on or before 'to'" });
return null; return null;
} }
return { action, fromDate, toDateExclusive }; return { action, forcedOnly, fromDate, toDateExclusive };
} }
function buildWhere(filters: AuditFilters): SQL | undefined { function buildWhere(filters: AuditFilters): SQL | undefined {
const conditions: SQL[] = []; const conditions: SQL[] = [];
if (filters.action) conditions.push(eq(auditLogsTable.action, filters.action)); if (filters.forcedOnly) {
// Either an explicit *.force_delete action, or a legacy *.delete row whose
// metadata records that the deletion was forced (force: true).
const forcedClause = or(
inArray(auditLogsTable.action, [...FORCE_DELETE_ACTIONS]),
and(
inArray(auditLogsTable.action, [...SOFT_DELETE_ACTIONS_WITH_FORCE_FLAG]),
sql`${auditLogsTable.metadata} @> '{"force": true}'::jsonb`,
),
);
if (forcedClause) conditions.push(forcedClause);
} else if (filters.action) {
conditions.push(eq(auditLogsTable.action, filters.action));
}
if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate)); if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate));
if (filters.toDateExclusive) if (filters.toDateExclusive)
conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive)); conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive));
@@ -0,0 +1,214 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
let adminId;
let adminUsername;
let adminCookie;
const insertedAuditIds = [];
// Tag every seeded audit row's metadata with this token so the test can
// reliably identify its own rows even though `target_id` is a plain integer.
const TEST_TAG = `forced_only_test_${STAMP}`;
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
return setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
}
async function insertAuditRow({ action, targetType, targetId, metadata }) {
const r = await pool.query(
`INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id`,
[
adminId,
action,
targetType,
targetId,
JSON.stringify({ ...metadata, testTag: TEST_TAG }),
],
);
insertedAuditIds.push(r.rows[0].id);
return r.rows[0].id;
}
// We don't care about the actual integer target_id values — only whether the
// row matches the filter. Use an arbitrary unique-per-row int.
let nextTargetId = 900000000;
const seedRow = (action, targetType, metadata) =>
insertAuditRow({
action,
targetType,
targetId: nextTargetId++,
metadata,
});
before(async () => {
adminUsername = `audit_admin_${STAMP}`;
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Audit Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
// Each of the four explicit *.force_delete actions.
await seedRow("group.force_delete", "group", { name: "GrpA", memberCount: 3 });
await seedRow("user.force_delete", "user", { username: "UsrA", noteCount: 2 });
await seedRow("app.force_delete", "app", { nameEn: "AppA", groupCount: 1 });
await seedRow("service.force_delete", "service", { nameEn: "SvcA", orderCount: 5 });
// Legacy soft-delete rows with metadata.force=true.
await seedRow("group.delete", "group", { name: "GrpB", force: true, memberCount: 1 });
await seedRow("user.delete", "user", { username: "UsrB", force: true, messageCount: 4 });
await seedRow("app.delete", "app", { nameEn: "AppB", force: true, groupCount: 2 });
// Non-forced rows that must NOT match the forcedOnly filter.
await seedRow("group.delete", "group", { name: "GrpC", force: false });
await seedRow("user.delete", "user", { username: "UsrC" });
await seedRow("app.create", "app", { nameEn: "AppC" });
});
after(async () => {
if (insertedAuditIds.length) {
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
insertedAuditIds,
]);
}
if (adminId !== undefined) {
await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [adminId]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]);
await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]);
}
await pool.end();
});
function ownEntries(body) {
return body.entries.filter(
(e) =>
e.metadata &&
typeof e.metadata === "object" &&
e.metadata.testTag === TEST_TAG,
);
}
async function listForcedOnly() {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?forcedOnly=true&limit=200`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
return res.json();
}
test("forcedOnly=true returns explicit *.force_delete rows", async () => {
const body = await listForcedOnly();
const ours = ownEntries(body);
const actions = ours.map((e) => e.action);
assert.ok(actions.includes("group.force_delete"));
assert.ok(actions.includes("user.force_delete"));
assert.ok(actions.includes("app.force_delete"));
assert.ok(actions.includes("service.force_delete"));
});
test("forcedOnly=true includes legacy *.delete rows with metadata.force=true", async () => {
const body = await listForcedOnly();
const ours = ownEntries(body);
const legacyForced = ours.filter(
(e) =>
(e.action === "group.delete" ||
e.action === "user.delete" ||
e.action === "app.delete") &&
e.metadata?.force === true,
);
assert.equal(legacyForced.length, 3);
});
test("forcedOnly=true excludes non-force rows", async () => {
const body = await listForcedOnly();
const ours = ownEntries(body);
for (const e of ours) {
const isExplicit = e.action.endsWith(".force_delete");
const isLegacyForced =
(e.action === "group.delete" ||
e.action === "user.delete" ||
e.action === "app.delete") &&
e.metadata?.force === true;
assert.ok(
isExplicit || isLegacyForced,
`unexpected non-forced row in result: ${e.action} (force=${e.metadata?.force})`,
);
}
});
test("forcedOnly overrides action filter when both are provided", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?forcedOnly=true&action=app.create&limit=200`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
const body = await res.json();
const ours = ownEntries(body);
// No app.create rows should appear because forcedOnly takes precedence.
assert.ok(!ours.some((e) => e.action === "app.create"));
// But the seeded force-delete rows should still appear.
assert.ok(ours.some((e) => e.action === "group.force_delete"));
});
test("forcedOnly=false (default) ignores the force filter", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?action=app.create&limit=200`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
const body = await res.json();
const ours = ownEntries(body);
assert.ok(ours.some((e) => e.action === "app.create"));
});
test("CSV export honors forcedOnly", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs/export?forcedOnly=true`,
{ headers: { Cookie: adminCookie, Accept: "text/csv" } },
);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/csv/);
const text = await res.text();
// The seeded force-delete actions should appear; a non-forced create must not
// appear in OUR rows. Use the testTag to narrow to our rows.
const ourLines = text
.split(/\r?\n/)
.filter((line) => line.includes(TEST_TAG));
assert.ok(ourLines.length > 0, "expected at least one of our rows in CSV");
assert.ok(ourLines.some((l) => l.startsWith("group.force_delete,")));
assert.ok(ourLines.some((l) => l.startsWith("user.delete,")));
assert.ok(!ourLines.some((l) => l.startsWith("app.create,")));
});
+13 -2
View File
@@ -483,7 +483,9 @@
"from": "من", "from": "من",
"to": "إلى", "to": "إلى",
"apply": "تطبيق", "apply": "تطبيق",
"reset": "إعادة ضبط" "reset": "إعادة ضبط",
"forcedOnly": "عمليات الحذف القسري فقط",
"forcedOnlyHint": "عرض إدخالات الحذف القسري للمجموعات والمستخدمين والتطبيقات والخدمات فقط."
}, },
"target": "الهدف: {{type}} #{{id}}", "target": "الهدف: {{type}} #{{id}}",
"showing": "عرض {{shown}} من أصل {{total}}", "showing": "عرض {{shown}} من أصل {{total}}",
@@ -564,7 +566,16 @@
"message_two": "رسالتان", "message_two": "رسالتان",
"message_few": "{{count}} رسائل", "message_few": "{{count}} رسائل",
"message_many": "{{count}} رسالةً", "message_many": "{{count}} رسالةً",
"message_other": "{{count}} رسالة" "message_other": "{{count}} رسالة",
"open_zero": "بدون فتحات",
"open_one": "فتحة واحدة",
"open_two": "فتحتان",
"open_few": "{{count}} فتحات",
"open_many": "{{count}} فتحةً",
"open_other": "{{count}} فتحة"
},
"dependencies": {
"label": "التبعيات المُزالة"
}, },
"summary": { "summary": {
"app": { "app": {
+9 -2
View File
@@ -480,7 +480,9 @@
"from": "From", "from": "From",
"to": "To", "to": "To",
"apply": "Apply", "apply": "Apply",
"reset": "Reset" "reset": "Reset",
"forcedOnly": "Forced deletions only",
"forcedOnlyHint": "Show only force-delete entries for groups, users, apps, and services."
}, },
"target": "Target: {{type}} #{{id}}", "target": "Target: {{type}} #{{id}}",
"showing": "Showing {{shown}} of {{total}}", "showing": "Showing {{shown}} of {{total}}",
@@ -517,7 +519,12 @@
"conversation_one": "{{count}} conversation", "conversation_one": "{{count}} conversation",
"conversation_other": "{{count}} conversations", "conversation_other": "{{count}} conversations",
"message_one": "{{count}} message", "message_one": "{{count}} message",
"message_other": "{{count}} messages" "message_other": "{{count}} messages",
"open_one": "{{count}} open",
"open_other": "{{count}} opens"
},
"dependencies": {
"label": "Cleared dependencies"
}, },
"summary": { "summary": {
"app": { "app": {
+107 -4
View File
@@ -3959,6 +3959,44 @@ function formatAuditSummary(
} }
} }
// Map of well-known dependency-count metadata keys → unit i18n root used by
// `unitLabel`. Keys not in this map are skipped from the inline chip strip.
const DEPENDENCY_COUNT_KEYS: Record<string, string> = {
orderCount: "order",
messageCount: "message",
noteCount: "note",
conversationCount: "conversation",
groupCount: "group",
memberCount: "member",
appCount: "app",
roleCount: "role",
openCount: "open",
};
function isForceDeleteEntry(entry: AuditLogEntry): boolean {
const action = entry.action;
if (action.endsWith(".force_delete")) return true;
if (action === "user.delete" || action === "app.delete" || action === "group.delete") {
const meta = asRecord(entry.metadata);
return meta.force === true;
}
return false;
}
function dependencyChips(
entry: AuditLogEntry,
t: AuditTFunction,
): Array<{ key: string; label: string }> {
const meta = asRecord(entry.metadata);
const chips: Array<{ key: string; label: string }> = [];
for (const [metaKey, unitRoot] of Object.entries(DEPENDENCY_COUNT_KEYS)) {
const value = asNumber(meta[metaKey]);
if (value == null || value <= 0) continue;
chips.push({ key: metaKey, label: unitLabel(t, unitRoot, value) });
}
return chips;
}
function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) { function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -3967,6 +4005,8 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
const actor = entry.actor; const actor = entry.actor;
const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase(); const initial = (actorLabel(actor, lang)[0] ?? "?").toUpperCase();
const summary = formatAuditSummary(entry, t, lang); const summary = formatAuditSummary(entry, t, lang);
const isForced = isForceDeleteEntry(entry);
const chips = isForced ? dependencyChips(entry, t) : [];
return ( return (
<div <div
className="glass-panel rounded-2xl p-3 space-y-2" className="glass-panel rounded-2xl p-3 space-y-2"
@@ -4006,7 +4046,12 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
)} )}
<div className="flex flex-wrap items-center gap-1.5 mt-1"> <div className="flex flex-wrap items-center gap-1.5 mt-1">
<span <span
className="inline-block text-[11px] font-mono px-2 py-0.5 rounded-md bg-amber-100 text-amber-800" className={cn(
"inline-block text-[11px] font-mono px-2 py-0.5 rounded-md",
isForced
? "bg-rose-100 text-rose-800"
: "bg-amber-100 text-amber-800",
)}
data-testid={`audit-action-${entry.id}`} data-testid={`audit-action-${entry.id}`}
> >
{entry.action} {entry.action}
@@ -4015,6 +4060,23 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
{formatAuditTimestamp(entry.createdAt, lang)} {formatAuditTimestamp(entry.createdAt, lang)}
</span> </span>
</div> </div>
{chips.length > 0 && (
<div
className="flex flex-wrap items-center gap-1.5 mt-1.5"
data-testid={`audit-deps-${entry.id}`}
aria-label={t("admin.audit.dependencies.label")}
>
{chips.map((chip) => (
<span
key={chip.key}
className="inline-block text-[11px] px-2 py-0.5 rounded-md bg-rose-50 text-rose-700 border border-rose-100"
data-testid={`audit-dep-${entry.id}-${chip.key}`}
>
{chip.label}
</span>
))}
</div>
)}
</div> </div>
{hasMetadata && ( {hasMetadata && (
<button <button
@@ -4047,6 +4109,7 @@ function AuditLogPanel() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const [actionFilter, setActionFilter] = useState<string>(""); const [actionFilter, setActionFilter] = useState<string>("");
const [forcedOnly, setForcedOnly] = useState<boolean>(false);
const [fromInput, setFromInput] = useState<string>(""); const [fromInput, setFromInput] = useState<string>("");
const [toInput, setToInput] = useState<string>(""); const [toInput, setToInput] = useState<string>("");
const [appliedFrom, setAppliedFrom] = useState<string>(""); const [appliedFrom, setAppliedFrom] = useState<string>("");
@@ -4064,7 +4127,11 @@ function AuditLogPanel() {
const params: ListAuditLogsParams = { const params: ListAuditLogsParams = {
limit: pageLimit, limit: pageLimit,
offset: 0, offset: 0,
...(actionFilter ? { action: actionFilter } : {}), ...(forcedOnly
? { forcedOnly: true }
: actionFilter
? { action: actionFilter }
: {}),
...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}), ...(appliedTo ? { to: appliedTo } : {}),
}; };
@@ -4092,6 +4159,7 @@ function AuditLogPanel() {
const resetFilters = () => { const resetFilters = () => {
setActionFilter(""); setActionFilter("");
setForcedOnly(false);
setFromInput(""); setFromInput("");
setToInput(""); setToInput("");
setAppliedFrom(""); setAppliedFrom("");
@@ -4099,6 +4167,15 @@ function AuditLogPanel() {
setPageLimit(50); setPageLimit(50);
}; };
const toggleForcedOnly = () => {
setForcedOnly((v) => {
const next = !v;
if (next) setActionFilter("");
setPageLimit(50);
return next;
});
};
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200)); const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
const handleExportCsv = async () => { const handleExportCsv = async () => {
@@ -4107,7 +4184,11 @@ function AuditLogPanel() {
setExporting(true); setExporting(true);
try { try {
const url = getExportAuditLogsCsvUrl({ const url = getExportAuditLogsCsvUrl({
...(actionFilter ? { action: actionFilter } : {}), ...(forcedOnly
? { forcedOnly: true }
: actionFilter
? { action: actionFilter }
: {}),
...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}), ...(appliedTo ? { to: appliedTo } : {}),
}); });
@@ -4143,13 +4224,35 @@ function AuditLogPanel() {
return ( return (
<div className="space-y-3" data-testid="audit-log-panel"> <div className="space-y-3" data-testid="audit-log-panel">
<div className="glass-panel rounded-2xl p-3 space-y-3"> <div className="glass-panel rounded-2xl p-3 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggleForcedOnly}
aria-pressed={forcedOnly}
title={t("admin.audit.filters.forcedOnlyHint")}
data-testid="audit-forced-only-chip"
className={cn(
"inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border transition-colors",
forcedOnly
? "bg-rose-100 text-rose-800 border-rose-200"
: "bg-white/70 text-foreground border-slate-200 hover:bg-slate-50",
)}
>
<Trash2 size={12} />
{t("admin.audit.filters.forcedOnly")}
</button>
</div>
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-4"> <div className="grid gap-2 sm:grid-cols-2 md:grid-cols-4">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.action")}</Label> <Label className="text-xs">{t("admin.audit.filters.action")}</Label>
<select <select
value={actionFilter} value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)} onChange={(e) => setActionFilter(e.target.value)}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full" disabled={forcedOnly}
className={cn(
"bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full",
forcedOnly && "opacity-60 cursor-not-allowed",
)}
data-testid="audit-action-filter" data-testid="audit-action-filter"
> >
<option value="">{t("admin.audit.filters.allActions")}</option> <option value="">{t("admin.audit.filters.allActions")}</option>
@@ -1018,6 +1018,15 @@ export type ListAuditLogsParams = {
* Exact action name to filter by (e.g. "group.force_delete"). * Exact action name to filter by (e.g. "group.force_delete").
*/ */
action?: string; action?: string;
/**
* When true, restrict results to forced deletions only — i.e. rows
with action `group.force_delete`, `user.force_delete`,
`app.force_delete`, `service.force_delete`, or any
`group.delete`/`user.delete`/`app.delete` row whose metadata
contains `force: true`. Overrides the `action` filter when set.
*/
forcedOnly?: boolean;
/** /**
* Start date (inclusive, YYYY-MM-DD UTC). * Start date (inclusive, YYYY-MM-DD UTC).
*/ */
@@ -1042,6 +1051,13 @@ export type ExportAuditLogsCsvParams = {
* Exact action name to filter by (e.g. "group.force_delete"). * Exact action name to filter by (e.g. "group.force_delete").
*/ */
action?: string; action?: string;
/**
* When true, restrict the export to forced deletions only.
See the list endpoint for the exact set of matching rows.
Overrides the `action` filter when set.
*/
forcedOnly?: boolean;
/** /**
* Start date (inclusive, YYYY-MM-DD UTC). * Start date (inclusive, YYYY-MM-DD UTC).
*/ */
+2 -1
View File
@@ -6265,7 +6265,8 @@ export function useListAuditLogs<
/** /**
* Streams the filtered audit log as a CSV download. * Streams the filtered audit log as a CSV download.
Honors the same `action`, `from`, and `to` filters as the list endpoint. Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
list endpoint.
Columns: action, actor_username, target_type, target_id, created_at, metadata. Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size. Capped at 50,000 rows per export to bound response size.
+24 -1
View File
@@ -1811,6 +1811,18 @@ paths:
schema: schema:
type: string type: string
description: Exact action name to filter by (e.g. "group.force_delete"). description: Exact action name to filter by (e.g. "group.force_delete").
- in: query
name: forcedOnly
required: false
schema:
type: boolean
default: false
description: |
When true, restrict results to forced deletions only — i.e. rows
with action `group.force_delete`, `user.force_delete`,
`app.force_delete`, `service.force_delete`, or any
`group.delete`/`user.delete`/`app.delete` row whose metadata
contains `force: true`. Overrides the `action` filter when set.
- in: query - in: query
name: from name: from
required: false required: false
@@ -1861,7 +1873,8 @@ paths:
summary: Export filtered audit log as CSV (admin) summary: Export filtered audit log as CSV (admin)
description: | description: |
Streams the filtered audit log as a CSV download. Streams the filtered audit log as a CSV download.
Honors the same `action`, `from`, and `to` filters as the list endpoint. Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
list endpoint.
Columns: action, actor_username, target_type, target_id, created_at, metadata. Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size. Capped at 50,000 rows per export to bound response size.
parameters: parameters:
@@ -1871,6 +1884,16 @@ paths:
schema: schema:
type: string type: string
description: Exact action name to filter by (e.g. "group.force_delete"). description: Exact action name to filter by (e.g. "group.force_delete").
- in: query
name: forcedOnly
required: false
schema:
type: boolean
default: false
description: |
When true, restrict the export to forced deletions only.
See the list endpoint for the exact set of matching rows.
Overrides the `action` filter when set.
- in: query - in: query
name: from name: from
required: false required: false
+17 -1
View File
@@ -2250,6 +2250,7 @@ Filter by action (exact match) and/or a date range (inclusive).
* @summary List audit log entries (admin) * @summary List audit log entries (admin)
*/ */
export const listAuditLogsQueryForcedOnlyDefault = false;
export const listAuditLogsQueryLimitDefault = 50; export const listAuditLogsQueryLimitDefault = 50;
export const listAuditLogsQueryLimitMax = 200; export const listAuditLogsQueryLimitMax = 200;
@@ -2261,6 +2262,12 @@ export const ListAuditLogsQueryParams = zod.object({
.string() .string()
.optional() .optional()
.describe('Exact action name to filter by (e.g. \"group.force_delete\").'), .describe('Exact action name to filter by (e.g. \"group.force_delete\").'),
forcedOnly: zod.coerce
.boolean()
.default(listAuditLogsQueryForcedOnlyDefault)
.describe(
"When true, restrict results to forced deletions only — i.e. rows\nwith action `group.force_delete`, `user.force_delete`,\n`app.force_delete`, `service.force_delete`, or any\n`group.delete`\/`user.delete`\/`app.delete` row whose metadata\ncontains `force: true`. Overrides the `action` filter when set.\n",
),
from: zod from: zod
.date() .date()
.optional() .optional()
@@ -2311,17 +2318,26 @@ export const ListAuditLogsResponse = zod.object({
/** /**
* Streams the filtered audit log as a CSV download. * Streams the filtered audit log as a CSV download.
Honors the same `action`, `from`, and `to` filters as the list endpoint. Honors the same `action`, `forcedOnly`, `from`, and `to` filters as the
list endpoint.
Columns: action, actor_username, target_type, target_id, created_at, metadata. Columns: action, actor_username, target_type, target_id, created_at, metadata.
Capped at 50,000 rows per export to bound response size. Capped at 50,000 rows per export to bound response size.
* @summary Export filtered audit log as CSV (admin) * @summary Export filtered audit log as CSV (admin)
*/ */
export const exportAuditLogsCsvQueryForcedOnlyDefault = false;
export const ExportAuditLogsCsvQueryParams = zod.object({ export const ExportAuditLogsCsvQueryParams = zod.object({
action: zod.coerce action: zod.coerce
.string() .string()
.optional() .optional()
.describe('Exact action name to filter by (e.g. \"group.force_delete\").'), .describe('Exact action name to filter by (e.g. \"group.force_delete\").'),
forcedOnly: zod.coerce
.boolean()
.default(exportAuditLogsCsvQueryForcedOnlyDefault)
.describe(
"When true, restrict the export to forced deletions only.\nSee the list endpoint for the exact set of matching rows.\nOverrides the `action` filter when set.\n",
),
from: zod from: zod
.date() .date()
.optional() .optional()