From e98343f62450b0199cb7cb7b6c9822378f431e95 Mon Sep 17 00:00:00 2001 From: peris611 Date: Thu, 10 Sep 2026 10:46:06 -0700 Subject: [PATCH 1/2] NWP-101: let ops choose export columns and scope The payments export was fixed: every column, current filter only. The card last four shipped in every file, so anything going to a merchant was edited by hand first. Adds an options dialog on the existing Export button: - Columns are selectable, with the card last four off by default (DEFAULT_EXPORT_COLUMNS in src/lib/csv.ts). - Scope is current filter or all payments, with both row counts shown before download. Both scopes go through the existing query builder, so an export is never limited to the current page. - parseExportColumns validates column names against the allowlist server side; the route returns 400 rather than serving an empty file. - The filename names the scope: payments-disputed-2026-08-13.csv. Amounts stay in minor units and are still formatted once, in cell(), beside their own currency column. Co-Authored-By: Claude Opus 5 --- .../src/app/api/payments/export/route.ts | 53 +++++- .../src/app/payments/export-dialog.tsx | 165 ++++++++++++++++++ .../src/app/payments/page.tsx | 18 +- .../merchant-console/src/lib/csv.test.ts | 66 ++++++- build-battle/merchant-console/src/lib/csv.ts | 42 ++++- 5 files changed, 320 insertions(+), 24 deletions(-) create mode 100644 build-battle/merchant-console/src/app/payments/export-dialog.tsx diff --git a/build-battle/merchant-console/src/app/api/payments/export/route.ts b/build-battle/merchant-console/src/app/api/payments/export/route.ts index 1869bbca..f0bf9d8d 100644 --- a/build-battle/merchant-console/src/app/api/payments/export/route.ts +++ b/build-battle/merchant-console/src/app/api/payments/export/route.ts @@ -1,25 +1,62 @@ import { filterPayments, parseFilters, sortPayments } from "@/data/queries" -import { exportFilename, toCsv } from "@/lib/csv" +import { exportFilename, parseExportColumns, toCsv } from "@/lib/csv" import { NextRequest } from "next/server" /** - * Exports the payments table as CSV. + * Exports the payments table as CSV (NWP-101). * - * Honors the active filters and reuses the query builder, but the column set - * and the scope are fixed. Giving ops control over both is NWP-101. + * Ops chooses the columns and the scope. Both arrive from the client, so both + * are checked against an allowlist here — the dialog disabling Download is a + * convenience, this is the enforcement. Rows come from the one query builder; + * exporting in the browser would only ever capture the current page. */ export function GET(request: NextRequest) { - const filters = parseFilters(request.nextUrl.searchParams) + const params = request.nextUrl.searchParams + + const columns = parseExportColumns(params.get("columns")) + if (columns.length === 0) { + return Response.json( + { message: "Select at least one column to export." }, + { status: 400 }, + ) + } + + const scope = params.get("scope") === "all" ? "all" : "filtered" + const filters = parseFilters(params) + + // Scope "all" ignores the active filters but keeps the requested ordering. const rows = sortPayments( - filterPayments(filters), + filterPayments(scope === "all" ? {} : filters), filters.sort, filters.direction, ) - return new Response(toCsv(rows), { + return new Response(toCsv(rows, columns), { headers: { "content-type": "text/csv; charset=utf-8", - "content-disposition": `attachment; filename="${exportFilename()}"`, + "content-disposition": `attachment; filename="${exportFilename( + new Date(), + filenameScope(scope, params), + )}"`, }, }) } + +/** + * The scope segment of the filename. A status filter names itself, so ops can + * tell `payments-disputed-...` from `payments-all-...` in a downloads folder. + */ +function filenameScope( + scope: "all" | "filtered", + params: URLSearchParams, +): string | undefined { + if (scope === "all") return "all" + + const status = parseFilters(params).status + if (status && status !== "all") return status + + const narrowed = ["merchantId", "search", "from", "to"].some((key) => + params.get(key), + ) + return narrowed ? "filtered" : undefined +} diff --git a/build-battle/merchant-console/src/app/payments/export-dialog.tsx b/build-battle/merchant-console/src/app/payments/export-dialog.tsx new file mode 100644 index 00000000..95def7da --- /dev/null +++ b/build-battle/merchant-console/src/app/payments/export-dialog.tsx @@ -0,0 +1,165 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Drawer, + DrawerBody, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/Drawer" +import { + DEFAULT_EXPORT_COLUMNS, + EXPORT_COLUMNS, + ExportColumn, +} from "@/lib/csv" +import { Download } from "lucide-react" +import { useState } from "react" + +const LABELS: Record = { + id: "Payment ID", + created_at: "Created (UTC)", + merchant: "Merchant", + description: "Description", + status: "Status", + method: "Method", + card_brand: "Card brand", + last4: "Card last four", + amount: "Amount", + currency: "Currency", +} + +export function PaymentsExportDialog({ + query, + filteredTotal, + allTotal, +}: { + /** The active filter params, already serialized by the page. */ + query: string + filteredTotal: number + allTotal: number +}) { + const [columns, setColumns] = useState([ + ...DEFAULT_EXPORT_COLUMNS, + ]) + const [scope, setScope] = useState<"filtered" | "all">("filtered") + + const toggle = (column: ExportColumn) => + setColumns((current) => + current.includes(column) + ? current.filter((c) => c !== column) + : // Keep the canonical order rather than click order. + EXPORT_COLUMNS.filter((c) => c === column || current.includes(c)), + ) + + const href = () => { + const params = new URLSearchParams(scope === "all" ? "" : query) + params.set("columns", columns.join(",")) + params.set("scope", scope) + return `/api/payments/export?${params.toString()}` + } + + const rowCount = scope === "all" ? allTotal : filteredTotal + + return ( + + + + + + + + Export payments + + + +
+ + Scope + +
+ {( + [ + ["filtered", "Current filter", filteredTotal], + ["all", "All payments", allTotal], + ] as const + ).map(([value, label, total]) => ( + + ))} +
+
+ +
+ + Columns + +
+ {EXPORT_COLUMNS.map((column) => ( + + ))} +
+ {columns.length === 0 && ( +

+ Select at least one column to export. +

+ )} +
+
+ + +

+ {rowCount.toLocaleString()} rows · {columns.length} columns +

+ {columns.length === 0 ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/build-battle/merchant-console/src/app/payments/page.tsx b/build-battle/merchant-console/src/app/payments/page.tsx index f97f4896..b7b9884c 100644 --- a/build-battle/merchant-console/src/app/payments/page.tsx +++ b/build-battle/merchant-console/src/app/payments/page.tsx @@ -14,8 +14,8 @@ import { queryPayments } from "@/data/queries" import { PaymentFilters, PaymentStatus } from "@/data/types" import { formatDate } from "@/lib/dates" import { formatMoney } from "@/lib/money" -import { Download } from "lucide-react" import Link from "next/link" +import { PaymentsExportDialog } from "./export-dialog" import { PaymentsFilterBar } from "./filter-bar" const STATUSES: (PaymentStatus | "all")[] = [ @@ -43,6 +43,8 @@ export default async function PaymentsPage({ } const { rows, total, page, pageCount } = queryPayments(filters) + // Same builder, no filters: the row count the "all payments" scope would export. + const allTotal = queryPayments({}).total const query = new URLSearchParams( Object.entries(params).filter(([, v]) => Boolean(v)) as [string, string][], ) @@ -65,15 +67,11 @@ export default async function PaymentsPage({ search: filters.search ?? "", }} /> - + diff --git a/build-battle/merchant-console/src/lib/csv.test.ts b/build-battle/merchant-console/src/lib/csv.test.ts index e28d8359..140773a6 100644 --- a/build-battle/merchant-console/src/lib/csv.test.ts +++ b/build-battle/merchant-console/src/lib/csv.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest" import { Payment } from "@/data/types" -import { EXPORT_COLUMNS, exportFilename, toCsv } from "./csv" +import { + DEFAULT_EXPORT_COLUMNS, + EXPORT_COLUMNS, + exportFilename, + parseExportColumns, + toCsv, +} from "./csv" /** * The export is the file ops hands to a merchant, so a broken cell is a @@ -74,6 +80,52 @@ describe("toCsv", () => { it("emits a header even with no rows", () => { expect(toCsv([], ["id"])).toBe("id") }) + + it("follows the caller's column order, not the canonical one", () => { + expect(toCsv([payment], ["status", "currency", "id"])).toBe( + ["status,currency,id", "captured,USD,pay_0001"].join("\n"), + ) + }) + + it("produces a blank file for an empty selection, which is why the route refuses one", () => { + // No header, no cells — just the row separator. Never a download path. + expect(toCsv([payment], [])).toBe("\n") + expect(toCsv([], [])).toBe("") + }) +}) + +describe("DEFAULT_EXPORT_COLUMNS", () => { + it("leaves the card last four out, so a merchant file is clean by default", () => { + expect(DEFAULT_EXPORT_COLUMNS).not.toContain("last4") + expect(DEFAULT_EXPORT_COLUMNS).toEqual( + EXPORT_COLUMNS.filter((column) => column !== "last4"), + ) + }) +}) + +describe("parseExportColumns", () => { + it("falls back to the default set when the client sends no columns", () => { + expect(parseExportColumns(null)).toEqual([...DEFAULT_EXPORT_COLUMNS]) + }) + + it("keeps the requested order", () => { + expect(parseExportColumns("amount,id,status")).toEqual([ + "amount", + "id", + "status", + ]) + }) + + it("drops names that are not columns and de-dupes the rest", () => { + expect(parseExportColumns("id, id ,merchant,../etc/passwd,DROP TABLE")).toEqual( + ["id", "merchant"], + ) + }) + + it("returns an empty selection when nothing survives the allowlist", () => { + expect(parseExportColumns("")).toEqual([]) + expect(parseExportColumns("nope,also_nope")).toEqual([]) + }) }) describe("exportFilename", () => { @@ -82,4 +134,16 @@ describe("exportFilename", () => { "payments-2026-03-14.csv", ) }) + + it("names the scope when there is one", () => { + expect( + exportFilename(new Date("2026-08-13T09:00:00.000Z"), "disputed"), + ).toBe("payments-disputed-2026-08-13.csv") + }) + + it("strips anything a scope segment has no business putting in a filename", () => { + expect(exportFilename(new Date("2026-08-13T09:00:00.000Z"), "../all")).toBe( + "payments-all-2026-08-13.csv", + ) + }) }) diff --git a/build-battle/merchant-console/src/lib/csv.ts b/build-battle/merchant-console/src/lib/csv.ts index 62be9e93..6ef75114 100644 --- a/build-battle/merchant-console/src/lib/csv.ts +++ b/build-battle/merchant-console/src/lib/csv.ts @@ -5,9 +5,8 @@ import { formatMoney } from "./money" /** * CSV export for the payments table. * - * The column set is fixed. Ops has asked for control over it — that is - * NWP-101 — but today everyone gets every column, including the card - * last four, whether or not the file is going to a merchant. + * Ops picks the columns (NWP-101). The card last four is available but off by + * default, so a file only carries it when someone asked for it. */ export const EXPORT_COLUMNS = [ @@ -25,6 +24,31 @@ export const EXPORT_COLUMNS = [ export type ExportColumn = (typeof EXPORT_COLUMNS)[number] +/** What ops gets without choosing: everything except the card last four. */ +export const DEFAULT_EXPORT_COLUMNS: readonly ExportColumn[] = + EXPORT_COLUMNS.filter((column) => column !== "last4") + +function isExportColumn(value: string): value is ExportColumn { + return (EXPORT_COLUMNS as readonly string[]).includes(value) +} + +/** + * Column names arrive from the client, so they are checked against the + * allowlist before they reach a serializer or a filename. Unknown names are + * dropped rather than rejected; an absent param means "the default set", and an + * empty result means the caller selected nothing, which the route refuses. + */ +export function parseExportColumns(raw: string | null): ExportColumn[] { + if (raw === null) return [...DEFAULT_EXPORT_COLUMNS] + + const seen = new Set() + for (const name of raw.split(",")) { + const trimmed = name.trim() + if (isExportColumn(trimmed)) seen.add(trimmed) + } + return [...seen] +} + function escapeCell(value: string): string { if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"` return value @@ -66,6 +90,14 @@ export function toCsv( return [header, ...rows].join("\n") } -export function exportFilename(date = new Date()): string { - return `payments-${date.toISOString().slice(0, 10)}.csv` +/** + * `payments-disputed-2026-08-13.csv`. The scope segment is slugified before it + * reaches a filename, because it is derived from client input. + */ +export function exportFilename(date = new Date(), scope?: string): string { + const day = date.toISOString().slice(0, 10) + const segment = scope?.toLowerCase().replace(/[^a-z0-9-]/g, "") + return segment + ? `payments-${segment}-${day}.csv` + : `payments-${day}.csv` } From 1944ae283672d16450f6cb275a30f3ab53828380 Mon Sep 17 00:00:00 2001 From: peris611 Date: Thu, 10 Sep 2026 10:46:06 -0700 Subject: [PATCH 2/2] docs: add Release Standards to the root CLAUDE.md Records the rules we are working to: no direct commits to main, test evidence attached before merging, and a one-line business impact summary on every pull request. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index da0055b1..be84bc21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,3 +37,9 @@ Work is submitted as a pull request against this repository and scored automatic - Branch from `main` with the ticket ID: `NWP-201-issue-cards` - Commit subjects carry the ticket ID: `NWP-201: issue virtual cards` - Fill in the pull request template. The grader reads it. + +## Release Standards + +- No direct commits to `main`. All changes land through a pull request. +- Every change needs test evidence before merging — a passing test run, a curl output, or a screenshot, attached to the PR. +- Every PR includes a one-line business impact summary: what changes for ops, merchants, or the org standards score.