Task #243: Admin audit log — focused readability + actor-filter subset

Landed a tight subset of the 13-item umbrella, mirroring the proven
narrow-then-defer pattern from #242:

- #195 — Plain DELETE /api/services/:id now writes a `service.delete`
  audit row carrying nameEn + nameAr (force-with-deps still uses the
  dedicated `service.force_delete`). Added matching `service.delete`
  formatter case + EN/AR i18n keys, and surfaced nameAr on the existing
  `service.force_delete` summary.
- #197 — `actorUserId` filter for `/admin/audit-logs` and CSV export.
  openapi.yaml updated, codegen regenerated, server filter wired through
  parseFilters/buildWhere with 400-on-invalid handling, AuditLogPanel UI
  got an actor dropdown wired into params + export URL + reset, and a
  new audit-logs-actor-filter API test (4 cases) covers list narrowing,
  exclusion, invalid input, and CSV export.
- #178 — Formatter unit tests for user.delete (id-only, EN/AR display
  name resolution, force flag, force + name) and the new service.delete
  (id-only, EN/AR), 11 new cases (33/33 pass).

Skipped #194 — already implemented; users.ts DELETE persists displayName
fields and audit-summary already renders user.deleteWithName/forceDeleteWithName.

Deferred via follow-ups (no duplicate of existing #182/#183/#184):
- F1: #196 recent-activity endpoint + 5 admin panels
- F2: #205+#206+#208 permission history CSV/name resolution/timeline
- F3: #209+#210 cascade/bulk audit rows + e2e UI spec for History tabs

Validation: tx-os typecheck clean; pre-existing executive-meetings.ts
errors not regressed; all targeted server tests pass (delete-force-warnings 10,
audit-logs target-filter 7, forced-only 6, audit-log-coverage 27, new
actor-filter 4, broader audit/services sweep 40); e2e test verified actor
dropdown rendering, filter behavior, readable Arabic service.delete summary,
and CSV export honoring the filter.
This commit is contained in:
Riyadh
2026-05-01 06:54:26 +00:00
parent 53af8351d7
commit d6b90db000
13 changed files with 577 additions and 6 deletions
+26 -1
View File
@@ -38,6 +38,8 @@ type AuditFilters = {
toDateExclusive: Date | null;
targetType: string;
targetId: number | null;
// #197: optional actor filter so admins can ask "what did user X do?"
actorUserId: number | null;
};
function parseFilters(req: Request, res: Response): AuditFilters | null {
@@ -51,6 +53,8 @@ function parseFilters(req: Request, res: Response): AuditFilters | null {
typeof req.query.targetType === "string" ? req.query.targetType.trim() : "";
const targetIdStr =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const actorUserIdStr =
typeof req.query.actorUserId === "string" ? req.query.actorUserId.trim() : "";
let fromDate: Date | null = null;
let toDateExclusive: Date | null = null;
@@ -82,7 +86,26 @@ function parseFilters(req: Request, res: Response): AuditFilters | null {
}
targetId = parsed;
}
return { action, forcedOnly, fromDate, toDateExclusive, targetType, targetId };
let actorUserId: number | null = null;
if (actorUserIdStr) {
const parsed = Number(actorUserIdStr);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
res
.status(400)
.json({ error: "Invalid 'actorUserId' (expected positive integer)" });
return null;
}
actorUserId = parsed;
}
return {
action,
forcedOnly,
fromDate,
toDateExclusive,
targetType,
targetId,
actorUserId,
};
}
function buildWhere(filters: AuditFilters): SQL | undefined {
@@ -108,6 +131,8 @@ function buildWhere(filters: AuditFilters): SQL | undefined {
conditions.push(eq(auditLogsTable.targetType, filters.targetType));
if (filters.targetId != null)
conditions.push(eq(auditLogsTable.targetId, filters.targetId));
if (filters.actorUserId != null)
conditions.push(eq(auditLogsTable.actorUserId, filters.actorUserId));
return conditions.length > 0 ? and(...conditions) : undefined;
}
@@ -164,6 +164,10 @@ router.delete("/services/:id", requireAdmin, async (req, res): Promise<void> =>
await tx.delete(servicesTable).where(eq(servicesTable.id, serviceId));
});
// #195: write an audit row for every service deletion. Forced deletes keep
// the dedicated `service.force_delete` action so the existing forced-only
// filter and metadata shape (orderCount) stay backwards compatible; plain
// deletes get a `service.delete` row mirroring the user.delete pattern.
if (hasDeps && force) {
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
@@ -172,9 +176,21 @@ router.delete("/services/:id", requireAdmin, async (req, res): Promise<void> =>
targetId: serviceId,
metadata: {
nameEn: existing.nameEn,
nameAr: existing.nameAr,
orderCount,
},
});
} else {
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "service.delete",
targetType: "service",
targetId: serviceId,
metadata: {
nameEn: existing.nameEn,
nameAr: existing.nameAr,
},
});
}
res.sendStatus(204);
@@ -0,0 +1,175 @@
// #197: Coverage for the actorUserId filter on GET /admin/audit-logs and the
// CSV export. Two distinct actor rows are seeded, then we assert the filter
// narrows to a single actor and that the export endpoint honours the same
// param. Tagging metadata so we ignore unrelated rows in the same database.
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)}`;
const TEST_TAG = `actor_filter_test_${STAMP}`;
let adminId;
let adminCookie;
let actorAId;
let actorBId;
const insertedAuditIds = [];
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({ actorUserId, action, targetType, targetId }) {
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`,
[
actorUserId,
action,
targetType,
targetId,
JSON.stringify({ testTag: TEST_TAG }),
],
);
insertedAuditIds.push(r.rows[0].id);
return r.rows[0].id;
}
before(async () => {
const adminUsername = `audit_actor_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, 'Actor 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);
// Two distinct actors who will each leave audit rows.
const aa = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Actor A', 'en', true) RETURNING id`,
[`actor_a_${STAMP}`, `actor_a_${STAMP}@example.com`, TEST_PASSWORD_HASH],
);
actorAId = aa.rows[0].id;
const bb = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Actor B', 'en', true) RETURNING id`,
[`actor_b_${STAMP}`, `actor_b_${STAMP}@example.com`, TEST_PASSWORD_HASH],
);
actorBId = bb.rows[0].id;
await insertAuditRow({
actorUserId: actorAId,
action: "settings.update",
targetType: "settings",
targetId: 1,
});
await insertAuditRow({
actorUserId: actorAId,
action: "settings.update",
targetType: "settings",
targetId: 1,
});
await insertAuditRow({
actorUserId: actorBId,
action: "settings.update",
targetType: "settings",
targetId: 1,
});
});
after(async () => {
if (insertedAuditIds.length > 0) {
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [
insertedAuditIds,
]);
}
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
[adminId, actorAId, actorBId].filter(Boolean),
]);
await pool.end();
});
function rowMatchesTag(entry) {
return entry.metadata && entry.metadata.testTag === TEST_TAG;
}
test("actorUserId filter narrows results to the chosen actor", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=${actorAId}&limit=200`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
const body = await res.json();
const tagged = body.entries.filter(rowMatchesTag);
assert.equal(tagged.length, 2);
for (const entry of tagged) {
assert.equal(entry.actor && entry.actor.id, actorAId);
}
});
test("actorUserId filter excludes other actors' rows", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=${actorBId}&limit=200`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
const body = await res.json();
const tagged = body.entries.filter(rowMatchesTag);
assert.equal(tagged.length, 1);
assert.equal(tagged[0].actor.id, actorBId);
});
test("invalid actorUserId returns 400", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs?actorUserId=not-a-number`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 400);
});
test("CSV export honours actorUserId filter", async () => {
const res = await fetch(
`${API_BASE}/api/admin/audit-logs/export?actorUserId=${actorAId}`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(res.status, 200);
assert.match(
res.headers.get("content-type") ?? "",
/text\/csv/i,
);
const text = await res.text();
// Filter to rows with our test tag in the metadata column. Two rows from
// Actor A, none from Actor B.
const taggedLines = text
.split(/\r?\n/)
.filter((line) => line.includes(TEST_TAG));
assert.equal(taggedLines.length, 2);
});
@@ -355,8 +355,8 @@ test("DELETE /api/apps/:id?force=true deletes app and writes audit log", async (
test("DELETE /api/services/:id without dependents succeeds (no force needed)", async () => {
const s = await pool.query(
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', $1, 1.00, true) RETURNING id`,
[`${SVC_PREFIX}Clean`],
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.00, true) RETURNING id`,
[`${SVC_PREFIX}CleanAr`, `${SVC_PREFIX}Clean`],
);
const id = s.rows[0].id;
createdServiceIds.push(id);
@@ -366,6 +366,43 @@ test("DELETE /api/services/:id without dependents succeeds (no force needed)", a
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
// #195: plain (no-deps) deletes now write a `service.delete` audit row
// carrying both nameEn and nameAr so the audit log can render the service
// name in either locale even after the row is gone.
const audit = await pool.query(
`SELECT action, metadata FROM audit_logs WHERE target_type = 'service' AND target_id = $1`,
[String(id)],
);
assert.equal(audit.rowCount, 1);
assert.equal(audit.rows[0].action, "service.delete");
assert.equal(audit.rows[0].metadata.nameEn, `${SVC_PREFIX}Clean`);
assert.equal(audit.rows[0].metadata.nameAr, `${SVC_PREFIX}CleanAr`);
});
test("DELETE /api/services/:id?force=true with no dependents writes service.delete (not service.force_delete)", async () => {
// #195: the `force` query has no effect when there are no dependents, so
// the plain `service.delete` row is correct here. `service.force_delete`
// is reserved for the path that actually had to clear dependent orders.
const s = await pool.query(
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.50, true) RETURNING id`,
[`${SVC_PREFIX}ForceCleanAr`, `${SVC_PREFIX}ForceClean`],
);
const id = s.rows[0].id;
createdServiceIds.push(id);
const res = await fetch(`${API_BASE}/api/services/${id}?force=true`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const audit = await pool.query(
`SELECT action FROM audit_logs WHERE target_type = 'service' AND target_id = $1`,
[String(id)],
);
assert.equal(audit.rowCount, 1);
assert.equal(audit.rows[0].action, "service.delete");
});
test("DELETE /api/services/:id with orders returns 409 with counts", async () => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -437,3 +437,199 @@ test("unknown action returns null so callers can hide the summary line", () => {
);
assert.equal(result, null);
});
// #178: cover the user.delete branches added by #194 (display name resolution
// across EN/AR locales) and the user.force_delete branches that share the same
// shape, so the formatter doesn't silently regress when callers tweak metadata.
test("user.delete uses id-only key when username is missing", () => {
const result = formatAuditSummary(
entry({
action: "user.delete",
targetType: "user",
targetId: 42,
metadata: {},
}),
makeT(),
"en",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.user.delete");
assert.equal(opts.username, "#42");
});
test("user.delete uses delete (no name) key when only username present", () => {
const result = formatAuditSummary(
entry({
action: "user.delete",
targetType: "user",
targetId: 7,
metadata: { username: "alice" },
}),
makeT(),
"en",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.user.delete");
assert.equal(opts.username, "alice");
});
test("user.delete picks displayNameEn in EN locale when both names present", () => {
const result = formatAuditSummary(
entry({
action: "user.delete",
targetType: "user",
targetId: 7,
metadata: {
username: "alice",
displayNameEn: "Alice Smith",
displayNameAr: "أليس",
},
}),
makeT(),
"en",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.user.deleteWithName");
assert.equal(opts.username, "alice");
assert.equal(opts.displayName, "Alice Smith");
});
test("user.delete picks displayNameAr in AR locale when both names present", () => {
const result = formatAuditSummary(
entry({
action: "user.delete",
targetType: "user",
targetId: 7,
metadata: {
username: "alice",
displayNameEn: "Alice Smith",
displayNameAr: "أليس",
},
}),
makeT(),
"ar",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.user.deleteWithName");
assert.equal(opts.displayName, "أليس");
});
test("user.delete falls back to displayNameEn in AR locale when AR missing", () => {
const result = formatAuditSummary(
entry({
action: "user.delete",
targetType: "user",
targetId: 7,
metadata: { username: "alice", displayNameEn: "Alice Smith" },
}),
makeT(),
"ar",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.user.deleteWithName");
assert.equal(opts.displayName, "Alice Smith");
});
test("user.delete with force flag in metadata routes to forceDelete branch", () => {
const result = formatAuditSummary(
entry({
action: "user.delete",
targetType: "user",
targetId: 7,
metadata: { username: "bob", force: true },
}),
makeT(),
"en",
);
const { key } = parse(result);
assert.equal(key, "admin.audit.summary.user.forceDelete");
});
test("user.delete with force + display name uses forceDeleteWithName branch", () => {
const result = formatAuditSummary(
entry({
action: "user.delete",
targetType: "user",
targetId: 7,
metadata: {
username: "bob",
displayNameEn: "Bob Jones",
force: true,
},
}),
makeT(),
"en",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.user.forceDeleteWithName");
assert.equal(opts.displayName, "Bob Jones");
});
// #195: plain service.delete now writes an audit row; cover the new summary
// branch (with/without nameEn/nameAr) plus the existing service.force_delete
// path which now also reads nameAr in the AR locale.
test("service.delete uses deleteId fallback when no name in metadata", () => {
const result = formatAuditSummary(
entry({
action: "service.delete",
targetType: "service",
targetId: 99,
metadata: {},
}),
makeT(),
"en",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.service.deleteId");
assert.equal(opts.id, 99);
});
test("service.delete prefers nameEn in EN locale", () => {
const result = formatAuditSummary(
entry({
action: "service.delete",
targetType: "service",
targetId: 99,
metadata: { nameEn: "Coffee", nameAr: "قهوة" },
}),
makeT(),
"en",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.service.delete");
assert.equal(opts.name, "Coffee");
});
test("service.delete prefers nameAr in AR locale", () => {
const result = formatAuditSummary(
entry({
action: "service.delete",
targetType: "service",
targetId: 99,
metadata: { nameEn: "Coffee", nameAr: "قهوة" },
}),
makeT(),
"ar",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.service.delete");
assert.equal(opts.name, "قهوة");
});
test("service.force_delete uses nameAr in AR locale when both names present", () => {
const result = formatAuditSummary(
entry({
action: "service.force_delete",
targetType: "service",
targetId: 99,
metadata: { nameEn: "Coffee", nameAr: "قهوة", orderCount: 3 },
}),
makeT(),
"ar",
);
const { key, opts } = parse(result);
assert.equal(key, "admin.audit.summary.service.forceDelete");
assert.equal(opts.name, "قهوة");
});
+18 -2
View File
@@ -281,11 +281,27 @@ export function formatAuditSummary(
asString(meta.permissionName) ??
(asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"),
});
case "service.force_delete":
case "service.delete": {
// #195: plain (non-force) service deletions get a friendlier summary
// mirroring user.delete. Falls back to id when nothing readable exists.
const nameEn = asString(meta.nameEn);
const nameAr = asString(meta.nameAr);
const name = lang === "ar" ? nameAr ?? nameEn : nameEn ?? nameAr;
return name
? t("admin.audit.summary.service.delete", { name })
: t("admin.audit.summary.service.deleteId", {
id: targetId ?? "?",
});
}
case "service.force_delete": {
const nameEn = asString(meta.nameEn);
const nameAr = asString(meta.nameAr);
const name = lang === "ar" ? nameAr ?? nameEn : nameEn ?? nameAr;
return t("admin.audit.summary.service.forceDelete", {
name: asString(meta.nameEn) ?? `#${targetId ?? "?"}`,
name: name ?? `#${targetId ?? "?"}`,
orders: unitLabel(t, "order", asNumber(meta.orderCount) ?? 0),
});
}
case "settings.update": {
const changes = asRecord(meta.changes);
const totalChanges = Object.keys(changes).length;
+4
View File
@@ -658,6 +658,8 @@
"filters": {
"action": "الإجراء",
"allActions": "كل الإجراءات",
"actor": "المنفّذ",
"allActors": "كل المنفّذين",
"from": "من",
"to": "إلى",
"apply": "تطبيق",
@@ -803,6 +805,8 @@
"permissionRemove": "تمت إزالة الصلاحية '{{permission}}' من الدور '{{name}}'"
},
"service": {
"delete": "تم حذف الخدمة '{{name}}'",
"deleteId": "تم حذف الخدمة رقم {{id}}",
"forceDelete": "تم حذف الخدمة '{{name}}' قسراً (تأثرت {{orders}})"
},
"settings": {
+4
View File
@@ -607,6 +607,8 @@
"filters": {
"action": "Action",
"allActions": "All actions",
"actor": "Actor",
"allActors": "All actors",
"from": "From",
"to": "To",
"apply": "Apply",
@@ -704,6 +706,8 @@
"permissionRemove": "Removed permission '{{permission}}' from role '{{name}}'"
},
"service": {
"delete": "Deleted service '{{name}}'",
"deleteId": "Deleted service #{{id}}",
"forceDelete": "Force-deleted service '{{name}}' (affected {{orders}})"
},
"settings": {
+54 -1
View File
@@ -5981,6 +5981,9 @@ function AuditLogPanel() {
const [pageLimit, setPageLimit] = useState<number>(50);
const [exporting, setExporting] = useState<boolean>(false);
const [exportError, setExportError] = useState<string | null>(null);
// #197: actor filter — applies immediately like the History tabs do, since
// picking a different person is the natural way to commit the choice.
const [actorUserId, setActorUserId] = useState<number | "">("");
// Deep-link target filters. Initialised from the URL hash so a user who
// pastes /admin#section=audit-log&targetType=group lands directly on a
// pre-filtered view. Updated by chip clicks and the explicit clear button.
@@ -6009,8 +6012,30 @@ function AuditLogPanel() {
...(appliedTo ? { to: appliedTo } : {}),
...(targetTypeFilter ? { targetType: targetTypeFilter } : {}),
...(targetIdFilter != null ? { targetId: targetIdFilter } : {}),
...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}),
};
// #197: list of users for the actor dropdown. Reuses the existing admin
// users query (already cached for the rest of the admin page).
const { data: usersForActor } = useListUsers(undefined, {
query: { queryKey: getListUsersQueryKey() },
});
const sortedActors = useMemo(() => {
const list = (usersForActor ?? []).slice();
list.sort((a, b) => {
const aLabel =
(lang === "ar"
? a.displayNameAr ?? a.displayNameEn
: a.displayNameEn ?? a.displayNameAr) ?? a.username;
const bLabel =
(lang === "ar"
? b.displayNameAr ?? b.displayNameEn
: b.displayNameEn ?? b.displayNameAr) ?? b.username;
return aLabel.localeCompare(bLabel);
});
return list;
}, [usersForActor, lang]);
const queryKey = getListAuditLogsQueryKey(params);
const { data, isLoading, isFetching } = useListAuditLogs(params, {
query: { queryKey, enabled: filtersValid },
@@ -6042,6 +6067,7 @@ function AuditLogPanel() {
setPageLimit(50);
setTargetTypeFilter("");
setTargetIdFilter(null);
setActorUserId("");
syncAuditHashTarget({ targetType: "", targetId: null });
};
@@ -6089,6 +6115,7 @@ function AuditLogPanel() {
...(appliedTo ? { to: appliedTo } : {}),
...(targetTypeFilter ? { targetType: targetTypeFilter } : {}),
...(targetIdFilter != null ? { targetId: targetIdFilter } : {}),
...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}),
});
const res = await fetch(url, {
method: "GET",
@@ -6167,7 +6194,7 @@ function AuditLogPanel() {
</span>
)}
</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-5">
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.action")}</Label>
<select
@@ -6188,6 +6215,32 @@ function AuditLogPanel() {
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.actor")}</Label>
<select
value={actorUserId === "" ? "" : String(actorUserId)}
onChange={(e) => {
const v = e.target.value;
setActorUserId(v === "" ? "" : Number(v));
setPageLimit(50);
}}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
data-testid="audit-actor-filter"
>
<option value="">{t("admin.audit.filters.allActors")}</option>
{sortedActors.map((u) => {
const label =
(lang === "ar"
? u.displayNameAr ?? u.displayNameEn
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
return (
<option key={u.id} value={u.id}>
{label}
</option>
);
})}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.audit.filters.from")}</Label>
<Input
@@ -1260,6 +1260,13 @@ of a single entity.
* @minimum 1
*/
targetId?: number;
/**
* Restrict results to entries written by a specific acting user
(matches `actor_user_id`). Combines with the other filters.
* @minimum 1
*/
actorUserId?: number;
/**
* @minimum 1
* @maximum 200
@@ -1303,4 +1310,10 @@ Combines with `targetId` and the other filters.
* @minimum 1
*/
targetId?: number;
/**
* Restrict the export to entries written by a specific acting user.
* @minimum 1
*/
actorUserId?: number;
};
+17
View File
@@ -2346,6 +2346,15 @@ paths:
Restrict results to entries whose `target_id` exactly matches.
Typically used together with `targetType` to focus on the history
of a single entity.
- in: query
name: actorUserId
required: false
schema:
type: integer
minimum: 1
description: |
Restrict results to entries written by a specific acting user
(matches `actor_user_id`). Combines with the other filters.
- in: query
name: limit
required: false
@@ -2433,6 +2442,14 @@ paths:
minimum: 1
description: |
Restrict the export to entries whose `target_id` exactly matches.
- in: query
name: actorUserId
required: false
schema:
type: integer
minimum: 1
description: |
Restrict the export to entries written by a specific acting user.
responses:
"200":
description: CSV file download
+15
View File
@@ -2726,6 +2726,7 @@ Filter by action (exact match) and/or a date range (inclusive).
* @summary List audit log entries (admin)
*/
export const listAuditLogsQueryForcedOnlyDefault = false;
export const listAuditLogsQueryLimitDefault = 50;
export const listAuditLogsQueryLimitMax = 200;
@@ -2761,6 +2762,13 @@ export const ListAuditLogsQueryParams = zod.object({
.describe(
"Restrict results to entries whose `target_id` exactly matches.\nTypically used together with `targetType` to focus on the history\nof a single entity.\n",
),
actorUserId: zod.coerce
.number()
.min(1)
.optional()
.describe(
"Restrict results to entries written by a specific acting user\n(matches `actor_user_id`). Combines with the other filters.\n",
),
limit: zod.coerce
.number()
.min(1)
@@ -2844,4 +2852,11 @@ export const ExportAuditLogsCsvQueryParams = zod.object({
.describe(
"Restrict the export to entries whose `target_id` exactly matches.\n",
),
actorUserId: zod.coerce
.number()
.min(1)
.optional()
.describe(
"Restrict the export to entries written by a specific acting user.\n",
),
});