feat: add CSV/JSON export, advanced bulk filters & selection to Catalog Manager
- 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:
1 parent
eeca532057
commit
388d8992f8
8 files changed
+271
-54
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -134,12 +134,27 @@ export function AuditTable({ data }: AuditTableProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AuditFilters />
|
<AuditFilters />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
|
<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
|
<DataTable
|
||||||
data={data}
|
data={data}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||||
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ vi.mock("@/lib/services/audit", () => ({
|
|||||||
import { getAuditLogs } from "@/lib/services/audit";
|
import { getAuditLogs } from "@/lib/services/audit";
|
||||||
import { GET } from "./route";
|
import { GET } from "./route";
|
||||||
|
|
||||||
describe("audit CSV", () => {
|
describe("audit export", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getAuditLogs).mockClear();
|
vi.mocked(getAuditLogs).mockClear();
|
||||||
});
|
});
|
||||||
@@ -54,6 +54,19 @@ describe("audit CSV", () => {
|
|||||||
});
|
});
|
||||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
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 () => {
|
it("escapes CSV formula cells and redacts historical secrets", async () => {
|
||||||
const response = await GET(
|
const response = await GET(
|
||||||
new Request("https://example.test/api/admin/logs/audit/export") as never,
|
new Request("https://example.test/api/admin/logs/audit/export") as never,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const GET = withAdmin(
|
|||||||
from: params.get("from") ?? undefined,
|
from: params.get("from") ?? undefined,
|
||||||
to: params.get("to") ?? undefined,
|
to: params.get("to") ?? undefined,
|
||||||
});
|
});
|
||||||
|
const format = params.get("format") || "csv";
|
||||||
const rows: unknown[][] = [
|
const rows: unknown[][] = [
|
||||||
[
|
[
|
||||||
"id",
|
"id",
|
||||||
@@ -35,8 +36,17 @@ export const GET = withAdmin(
|
|||||||
"changes",
|
"changes",
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
const jsonItems = [];
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const details = readAuditChanges(row.diff, row.before, row.after);
|
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([
|
rows.push([
|
||||||
row.id,
|
row.id,
|
||||||
row.userId,
|
row.userId,
|
||||||
@@ -45,15 +55,27 @@ export const GET = withAdmin(
|
|||||||
row.target,
|
row.target,
|
||||||
row.targetId,
|
row.targetId,
|
||||||
row.createdAt,
|
row.createdAt,
|
||||||
details.invalid
|
changesFormatted,
|
||||||
? "[Unavailable legacy details]"
|
|
||||||
: details.changes
|
|
||||||
.map(
|
|
||||||
(change) =>
|
|
||||||
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
|
|
||||||
)
|
|
||||||
.join("\n"),
|
|
||||||
]);
|
]);
|
||||||
|
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(
|
return new Response(
|
||||||
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
|
`\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("totalAccounts", { count: 42 })).toBe("42 total accounts");
|
||||||
expect(t("groups.players")).toBe("Players");
|
expect(t("groups.players")).toBe("Players");
|
||||||
expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34");
|
expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34");
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2331,12 +2331,18 @@
|
|||||||
"discovery": {
|
"discovery": {
|
||||||
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
|
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
|
||||||
"filterLabel": "Filter offers",
|
"filterLabel": "Filter offers",
|
||||||
|
"clubOnlyFilterLabel": "HC Only Filter",
|
||||||
|
"haveOfferFilterLabel": "Has Offer Filter",
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "All offers",
|
"all": "All offers",
|
||||||
"free": "Free",
|
"free": "Free",
|
||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
"bundles": "Bundles",
|
"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)",
|
"shown": "({count} shown)",
|
||||||
"selected": "{count} selected",
|
"selected": "{count} selected",
|
||||||
|
|||||||
Reference in new issue
Block a user