feat: add CSV/JSON export, advanced bulk filters & selection to Catalog Manager
CI / check (push) Failing after 1m1s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

- Admin Audit Log: CSV/JSON export via ?format=query param
- Catalog Manager: club_only / have_offer filters (multi-filter support)
- Offer discovery: refactored discoverOffers() to accept filters object
- Translations added for new filter labels
- Test updates for all modified paths
This commit is contained in:
openhands committed 2026-09-12 20:40:33 +02:00
1 parent eeca532057
commit 388d8992f8
8 files changed
+279 -62

No files matched your search

File diff suppressed because it is too large. Load diff
+17 -2
View File
@@ -134,12 +134,27 @@ export function AuditTable({ data }: AuditTableProps) {
return (
<div className="space-y-4">
<AuditFilters />
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
<div className="flex items-center gap-2">
<a
href={`/api/admin/logs/audit/export?${params.toString()}&format=csv`}
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Export CSV
</a>
<a
href={`/api/admin/logs/audit/export?${params.toString()}&format=json`}
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Export JSON
</a>
</div>
</div>
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("audit.searchPlaceholder")}
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
/>
</div>
);
@@ -31,7 +31,7 @@ vi.mock("@/lib/services/audit", () => ({
import { getAuditLogs } from "@/lib/services/audit";
import { GET } from "./route";
describe("audit CSV", () => {
describe("audit export", () => {
beforeEach(() => {
vi.mocked(getAuditLogs).mockClear();
});
@@ -54,6 +54,19 @@ describe("audit CSV", () => {
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
});
it("supports JSON export format", async () => {
const response = await GET(
new Request(
"https://example.test/api/admin/logs/audit/export?format=json",
) as never,
{} as never,
);
expect(response.headers.get("Content-Type")).toContain("application/json");
const json = await response.json();
expect(Array.isArray(json)).toBe(true);
expect(json[0].id).toBe(1);
expect(json[0].username).toBe('=HYPERLINK("bad")');
});
it("escapes CSV formula cells and redacts historical secrets", async () => {
const response = await GET(
new Request("https://example.test/api/admin/logs/audit/export") as never,
+30 -8
View File
@@ -23,6 +23,7 @@ export const GET = withAdmin(
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
});
const format = params.get("format") || "csv";
const rows: unknown[][] = [
[
"id",
@@ -35,8 +36,17 @@ export const GET = withAdmin(
"changes",
],
];
const jsonItems = [];
for (const row of result.rows) {
const details = readAuditChanges(row.diff, row.before, row.after);
const changesFormatted = details.invalid
? "[Unavailable legacy details]"
: details.changes
.map(
(change) =>
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
)
.join("\n");
rows.push([
row.id,
row.userId,
@@ -45,15 +55,27 @@ export const GET = withAdmin(
row.target,
row.targetId,
row.createdAt,
details.invalid
? "[Unavailable legacy details]"
: details.changes
.map(
(change) =>
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
)
.join("\n"),
changesFormatted,
]);
jsonItems.push({
id: row.id,
userId: row.userId,
username: row.username,
action: row.action,
target: row.target,
targetId: row.targetId,
createdAt: row.createdAt,
changes: details.changes,
});
}
if (format === "json") {
return new Response(JSON.stringify(jsonItems, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": 'attachment; filename="audit-page.json"',
"Cache-Control": "no-store",
},
});
}
return new Response(
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -48,6 +48,5 @@ describe("command center translations", () => {
expect(t("totalAccounts", { count: 42 })).toBe("42 total accounts");
expect(t("groups.players")).toBe("Players");
expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34");
});
});
+7 -1
View File
@@ -2331,12 +2331,18 @@
"discovery": {
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
"filterLabel": "Filter offers",
"clubOnlyFilterLabel": "HC Only Filter",
"haveOfferFilterLabel": "Has Offer Filter",
"filters": {
"all": "All offers",
"free": "Free",
"paid": "Paid",
"bundles": "Bundles",
"limited": "Limited editions"
"limited": "Limited editions",
"club_only_yes": "HC Only",
"club_only_no": "Not HC Only",
"have_offer_yes": "Has Offer",
"have_offer_no": "No Offer"
},
"shown": "({count} shown)",
"selected": "{count} selected",