From c04649ffc960e225d03a7e353244fe5756617485 Mon Sep 17 00:00:00 2001 From: Jemmuel Real Date: Tue, 22 Sep 2026 18:31:06 -0700 Subject: [PATCH 1/2] NWP-101: add column and scope options to the payments export Export now opens a dialog where ops chooses columns (card last four off by default) and scope (current filter or all payments), with row counts shown before download. The route validates columns and scope against allowlists, returns 400 on bad input, and names the file by scope and UTC date. Both scopes go through the existing query builder, unpaged. Adds a Dialog primitive on @radix-ui/react-dialog, and switches the payments page to parseFilters so the dialog's count matches the file. Co-Authored-By: Claude Opus 5.5 --- .../src/app/api/payments/export/route.ts | 42 ++++- .../src/app/payments/export-dialog.tsx | 175 +++++++++++++++++ .../src/app/payments/page.tsx | 36 ++-- .../src/components/Dialog.tsx | 176 ++++++++++++++++++ .../merchant-console/src/lib/csv.test.ts | 62 +++++- build-battle/merchant-console/src/lib/csv.ts | 61 +++++- 6 files changed, 517 insertions(+), 35 deletions(-) create mode 100644 build-battle/merchant-console/src/app/payments/export-dialog.tsx create mode 100644 build-battle/merchant-console/src/components/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..44b6debb 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,53 @@ import { filterPayments, parseFilters, sortPayments } from "@/data/queries" -import { exportFilename, toCsv } from "@/lib/csv" -import { NextRequest } from "next/server" +import { + exportFilename, + parseExportColumns, + parseExportScope, + toCsv, +} from "@/lib/csv" +import { NextRequest, NextResponse } from "next/server" /** * Exports the payments table as CSV. * - * 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 are validated before anything + * is read. Either scope goes through the one query builder, unpaginated, so + * the file holds every matching row rather than the page on screen. */ export function GET(request: NextRequest) { - const filters = parseFilters(request.nextUrl.searchParams) + const params = request.nextUrl.searchParams + + const columns = parseExportColumns(params.get("columns")) + if (!columns.ok) { + return NextResponse.json({ error: columns.error }, { status: 400 }) + } + + const scope = parseExportScope(params.get("scope")) + if (!scope.ok) { + return NextResponse.json({ error: scope.error }, { status: 400 }) + } + + const filters = parseFilters( + scope.value === "all" ? new URLSearchParams() : params, + ) const rows = sortPayments( filterPayments(filters), filters.sort, filters.direction, ) - return new Response(toCsv(rows), { + // Only validated values reach the filename: the scope or an allowlisted status. + const label = + scope.value === "all" + ? "all" + : filters.status && filters.status !== "all" + ? filters.status + : "filtered" + + return new Response(toCsv(rows, columns.value), { headers: { "content-type": "text/csv; charset=utf-8", - "content-disposition": `attachment; filename="${exportFilename()}"`, + "content-disposition": `attachment; filename="${exportFilename(new Date(), label)}"`, }, }) } 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..e7f3dadb --- /dev/null +++ b/build-battle/merchant-console/src/app/payments/export-dialog.tsx @@ -0,0 +1,175 @@ +"use client" + +import { Button } from "@/components/Button" +import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/Dialog" +import { + DEFAULT_EXPORT_COLUMNS, + EXPORT_COLUMNS, + ExportColumn, + ExportScope, +} from "@/lib/csv" +import { Download } from "lucide-react" +import { useState } from "react" + +const COLUMN_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", +} + +/** + * Options for the payments export. This only builds the request; the route + * validates the columns and scope again, because this is a convenience and + * not the enforcement. + */ +export function ExportDialog({ + query, + counts, +}: { + /** The current filter as a query string, without paging. */ + query: string + counts: Record +}) { + const [selected, setSelected] = useState([ + ...DEFAULT_EXPORT_COLUMNS, + ]) + const [scope, setScope] = useState("filter") + + const toggle = (column: ExportColumn, checked: boolean) => + setSelected((current) => + checked + ? // Keep the table's column order however the boxes were clicked. + EXPORT_COLUMNS.filter((c) => c === column || current.includes(c)) + : current.filter((c) => c !== column), + ) + + const params = new URLSearchParams(scope === "filter" ? query : "") + params.set("scope", scope) + params.set("columns", selected.join(",")) + const href = `/api/payments/export?${params.toString()}` + + const scopes: { value: ExportScope; label: string }[] = [ + { value: "filter", label: "Current filter" }, + { value: "all", label: "All payments" }, + ] + + return ( + + + + + + + Export payments + + Choose what goes in the CSV. Card last four is off unless you need it. + + + + +
+ + Scope + + {scopes.map(({ value, label }) => ( + + ))} +
+ +
+ + Columns + +
+ {EXPORT_COLUMNS.map((column) => ( + + ))} +
+
+
+ + + {selected.length === 0 && ( +

+ Choose at least one column. +

+ )} + + + + {selected.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..7489ced1 100644 --- a/build-battle/merchant-console/src/app/payments/page.tsx +++ b/build-battle/merchant-console/src/app/payments/page.tsx @@ -10,12 +10,12 @@ import { } from "@/components/Table" import { StatusBadge } from "@/components/ui/payments/StatusBadge" import { merchantById, merchants } from "@/data/merchants" -import { queryPayments } from "@/data/queries" -import { PaymentFilters, PaymentStatus } from "@/data/types" +import { filterPayments, parseFilters, queryPayments } from "@/data/queries" +import { 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 { ExportDialog } from "./export-dialog" import { PaymentsFilterBar } from "./filter-bar" const STATUSES: (PaymentStatus | "all")[] = [ @@ -33,19 +33,16 @@ export default async function PaymentsPage({ searchParams: Promise> }) { const params = await searchParams - const filters: PaymentFilters = { - status: (STATUSES.includes(params.status as PaymentStatus) - ? params.status - : "all") as PaymentFilters["status"], - merchantId: params.merchantId || undefined, - search: params.search || undefined, - page: Number(params.page ?? "1") || 1, - } - - const { rows, total, page, pageCount } = queryPayments(filters) const query = new URLSearchParams( Object.entries(params).filter(([, v]) => Boolean(v)) as [string, string][], ) + // The same parser the export route uses, so the count shown in the export + // dialog is the count of rows the file will contain. + const filters = parseFilters(query) + + const { rows, total, page, pageCount } = queryPayments(filters) + const exportQuery = new URLSearchParams(query) + exportQuery.delete("page") const pageHref = (next: number) => { const q = new URLSearchParams(query) @@ -65,15 +62,10 @@ export default async function PaymentsPage({ search: filters.search ?? "", }} /> - + diff --git a/build-battle/merchant-console/src/components/Dialog.tsx b/build-battle/merchant-console/src/components/Dialog.tsx new file mode 100644 index 00000000..ea72b6bf --- /dev/null +++ b/build-battle/merchant-console/src/components/Dialog.tsx @@ -0,0 +1,176 @@ +// Tremor Dialog [v0.0.1] + +import * as DialogPrimitives from "@radix-ui/react-dialog" +import { RiCloseLine } from "@remixicon/react" +import * as React from "react" + +import { cx, focusRing } from "@/lib/utils" + +import { Button } from "./Button" + +const Dialog = ( + props: React.ComponentPropsWithoutRef, +) => { + return +} +Dialog.displayName = "Dialog" + +const DialogTrigger = DialogPrimitives.Trigger + +DialogTrigger.displayName = "Dialog.Trigger" + +const DialogClose = DialogPrimitives.Close + +DialogClose.displayName = "Dialog.Close" + +const DialogPortal = DialogPrimitives.Portal + +DialogPortal.displayName = "DialogPortal" + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, forwardedRef) => { + return ( + + ) +}) + +DialogOverlay.displayName = "DialogOverlay" + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, forwardedRef) => { + return ( + + + + + + ) +}) + +DialogContent.displayName = "DialogContent" + +const DialogHeader = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<"div"> +>(({ children, className, ...props }, ref) => { + return ( +
+
+ {children} +
+ + + +
+ ) +}) + +DialogHeader.displayName = "Dialog.Header" + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, forwardedRef) => ( + +)) + +DialogTitle.displayName = "DialogTitle" + +const DialogBody = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => { + return
+}) +DialogBody.displayName = "Dialog.Body" + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, forwardedRef) => { + return ( + + ) +}) + +DialogDescription.displayName = "DialogDescription" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( +
+ ) +} + +DialogFooter.displayName = "DialogFooter" + +export { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} diff --git a/build-battle/merchant-console/src/lib/csv.test.ts b/build-battle/merchant-console/src/lib/csv.test.ts index e28d8359..4c7bf794 100644 --- a/build-battle/merchant-console/src/lib/csv.test.ts +++ b/build-battle/merchant-console/src/lib/csv.test.ts @@ -1,6 +1,13 @@ 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, + parseExportScope, + toCsv, +} from "./csv" /** * The export is the file ops hands to a merchant, so a broken cell is a @@ -76,10 +83,63 @@ describe("toCsv", () => { }) }) +describe("parseExportColumns", () => { + it("keeps a requested subset in the order given", () => { + const parsed = parseExportColumns("amount,id") + expect(parsed).toEqual({ ok: true, value: ["amount", "id"] }) + if (!parsed.ok) return + expect(toCsv([payment], parsed.value)).toBe( + ["amount,id", '$250.00,pay_0001'].join("\n"), + ) + }) + + it("leaves the card last four out when no columns are requested", () => { + const parsed = parseExportColumns(null) + expect(parsed).toEqual({ ok: true, value: [...DEFAULT_EXPORT_COLUMNS] }) + expect(DEFAULT_EXPORT_COLUMNS).not.toContain("last4") + const csv = toCsv([payment], DEFAULT_EXPORT_COLUMNS) + expect(csv.split("\n")[0]).not.toContain("last4") + expect(csv).not.toContain("4242") + }) + + it("rejects an empty selection rather than writing an empty file", () => { + expect(parseExportColumns("").ok).toBe(false) + expect(parseExportColumns(" , ").ok).toBe(false) + }) + + it("rejects a column that is not on the allowlist", () => { + expect(parseExportColumns("id,card_number")).toEqual({ + ok: false, + error: "Unknown export column: card_number.", + }) + }) + + it("collapses duplicates and trims whitespace", () => { + expect(parseExportColumns(" id , amount,id")).toEqual({ + ok: true, + value: ["id", "amount"], + }) + }) +}) + +describe("parseExportScope", () => { + it("defaults to the current filter and rejects anything unknown", () => { + expect(parseExportScope(null)).toEqual({ ok: true, value: "filter" }) + expect(parseExportScope("all")).toEqual({ ok: true, value: "all" }) + expect(parseExportScope("everything").ok).toBe(false) + }) +}) + describe("exportFilename", () => { it("stamps the UTC date, so two exports on the same day collide by design", () => { expect(exportFilename(new Date("2026-03-14T23:00:00.000Z"))).toBe( "payments-2026-03-14.csv", ) }) + + it("names the scope ahead of the date", () => { + expect( + exportFilename(new Date("2026-03-14T23:00:00.000Z"), "disputed"), + ).toBe("payments-disputed-2026-03-14.csv") + }) }) diff --git a/build-battle/merchant-console/src/lib/csv.ts b/build-battle/merchant-console/src/lib/csv.ts index 62be9e93..5eccf32e 100644 --- a/build-battle/merchant-console/src/lib/csv.ts +++ b/build-battle/merchant-console/src/lib/csv.ts @@ -5,9 +5,10 @@ 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 chooses the columns and the scope (NWP-101). Column names arrive from + * the client, so the route validates them here against EXPORT_COLUMNS before + * they select anything. The card last four is opt-in, because most files + * go to a merchant. */ export const EXPORT_COLUMNS = [ @@ -25,6 +26,51 @@ export const EXPORT_COLUMNS = [ export type ExportColumn = (typeof EXPORT_COLUMNS)[number] +/** What an export contains when ops has not chosen: everything but last4. */ +export const DEFAULT_EXPORT_COLUMNS: readonly ExportColumn[] = + EXPORT_COLUMNS.filter((column) => column !== "last4") + +export const EXPORT_SCOPES = ["filter", "all"] as const + +export type ExportScope = (typeof EXPORT_SCOPES)[number] + +type Parsed = { ok: true; value: T } | { ok: false; error: string } + +/** + * Validate a comma-separated column list from the client. Absent means the + * default set; present but empty is rejected rather than writing an empty + * file. Order is kept and duplicates collapse. + */ +export function parseExportColumns(raw: string | null): Parsed { + if (raw === null) return { ok: true, value: [...DEFAULT_EXPORT_COLUMNS] } + + const requested = raw + .split(",") + .map((name) => name.trim()) + .filter(Boolean) + if (requested.length === 0) { + return { ok: false, error: "Choose at least one column to export." } + } + + const unknown = requested.filter( + (name) => !EXPORT_COLUMNS.includes(name as ExportColumn), + ) + if (unknown.length > 0) { + return { ok: false, error: `Unknown export column: ${unknown[0]}.` } + } + + return { ok: true, value: [...new Set(requested)] as ExportColumn[] } +} + +/** Validate the export scope. Absent means the current filter. */ +export function parseExportScope(raw: string | null): Parsed { + if (raw === null) return { ok: true, value: "filter" } + if (EXPORT_SCOPES.includes(raw as ExportScope)) { + return { ok: true, value: raw as ExportScope } + } + return { ok: false, error: "Export scope must be \"filter\" or \"all\"." } +} + function escapeCell(value: string): string { if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"` return value @@ -66,6 +112,11 @@ 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 label must already be validated — + * a scope or an allowlisted status — never free text from the client. + */ +export function exportFilename(date = new Date(), label?: string): string { + const day = date.toISOString().slice(0, 10) + return label ? `payments-${label}-${day}.csv` : `payments-${day}.csv` } From 3763700c90fc852886a332bf9a6face10c6c4480 Mon Sep 17 00:00:00 2001 From: Jemmuel Real Date: Tue, 22 Sep 2026 18:31:06 -0700 Subject: [PATCH 2/2] NWP-101: correct console docs and add release standards Merchant console CLAUDE.md and README described seed data as JSON; it is generated in src/data/generate.ts. Adds test, lint and build commands and how pages reach the data. components.md listed a Dialog that did not exist. Root CLAUDE.md gains Release Standards, and the PR template gains a Business impact field. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 4 ++++ CLAUDE.md | 6 ++++++ .../.claude/rules/components.md | 2 +- build-battle/merchant-console/CLAUDE.md | 20 ++++++++++++++++--- build-battle/merchant-console/README.md | 4 ++-- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5ab28daa..e8976aac 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,6 +11,10 @@ Closes NWP-____ +## Business impact + + + ## How I verified it diff --git a/CLAUDE.md b/CLAUDE.md index da0055b1..81e5e1ed 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 + +- Every change needs test evidence before it merges. +- No direct commits to `main`. All work lands through a pull request. +- Every pull request includes a one-line business impact summary. diff --git a/build-battle/merchant-console/.claude/rules/components.md b/build-battle/merchant-console/.claude/rules/components.md index c2b8c9ec..41ea9cb3 100644 --- a/build-battle/merchant-console/.claude/rules/components.md +++ b/build-battle/merchant-console/.claude/rules/components.md @@ -6,7 +6,7 @@ paths: # Components -- **Use what is here.** `src/components/` already has Button, Input, Select, Dialog, Badge, and the rest, built on Tremor and Radix. Reach for those before adding a dependency or hand-rolling a control. +- **Use what is here.** `src/components/` already has Button, Input, Select, Drawer, Badge, and the rest, built on Tremor and Radix. Reach for those before adding a dependency or hand-rolling a control. There is no `Dialog` yet; if you need one, build it in `src/components/` on `@radix-ui/react-dialog` (already a dependency), following `Drawer.tsx`. - **Tailwind only.** No inline `style` attributes, no CSS modules. - **Dialogs and forms must be operable.** Every input has a label, the dialog has an accessible name, focus moves into it and returns on close, Escape closes it. - **Format money here, not upstream.** Components receive minor units and a currency code and render the string. diff --git a/build-battle/merchant-console/CLAUDE.md b/build-battle/merchant-console/CLAUDE.md index 78404a38..5051e828 100644 --- a/build-battle/merchant-console/CLAUDE.md +++ b/build-battle/merchant-console/CLAUDE.md @@ -13,13 +13,27 @@ npm run dev No database, no seed step, no Docker. +```bash +npm test # vitest run, node env, <1s +npx vitest run src/lib/csv.test.ts # one file +npx vitest run -t "quotes cells" # one test by name +npm run lint # next lint (core-web-vitals) +npm run build # also type-checks +``` + +Vitest runs in plain Node with no DOM and only picks up `src/**/*.test.ts`, so component tests will not run without changing `vitest.config.ts`. Imports use the `@/*` alias for `src/*`. + ## Data lives in memory -Seed data is JSON, loaded into a store module at boot. Route handlers read and write that store. +Seed data is generated deterministically in `src/data/generate.ts` (fixed seed, so every machine gets identical records) and held in a `globalThis` store in `src/data/store.ts`. Route handlers and pages read and write that store. - Writes last for the life of the dev server and vanish on restart. That is expected. - Persistence is tracked separately as NWP-203. **Do not add a database, an ORM, or migrations.** -- If you need more seed data, add it to the JSON. Never edit seed data to make a failing case disappear. +- To add data, change the generator. Never special-case records to make a failing case disappear. + +## How requests reach the data + +Pages under `src/app/` are server components that import from `src/data/` directly; route handlers exist only where the browser needs a response (`GET /api/payments` for the paged JSON list, `GET /api/payments/export` for the unpaged CSV). Filter state lives in the URL. New filtering still goes through `parseFilters` → `filterPayments`/`sortPayments` → `paginate` in `src/data/queries.ts`. ## Where the rest of the context lives @@ -53,7 +67,7 @@ These four explain most of the code, and breaking them is how bugs get in here. | --- | --- | | `src/app/` | Console routes: overview, payments, disputes, payouts. Cards is NWP-201 and does not exist yet | | `src/app/api/` | Route handlers | -| `src/data/` | Seed JSON, the in-memory store, and types | +| `src/data/` | Seed generator, the in-memory store, the query builder, and types | | `src/components/` | Tremor-based primitives and the console's own components | | `src/lib/` | Money, date, and CSV helpers, each with a `.test.ts` beside it. Read these before touching an amount | diff --git a/build-battle/merchant-console/README.md b/build-battle/merchant-console/README.md index 661cdfca..ba3d4d0f 100644 --- a/build-battle/merchant-console/README.md +++ b/build-battle/merchant-console/README.md @@ -23,7 +23,7 @@ Vitest, node environment, no DOM. The suite covers the money, date, and CSV helpers — the three places a quiet mistake costs real money. It runs in under a second, which is the point: it is meant to run before every push. -Data lives in an in-memory store loaded from JSON at boot. Anything you create lasts for the life of the dev server and resets on restart. That is deliberate — see [`CLAUDE.md`](./CLAUDE.md). +Data lives in an in-memory store, generated deterministically at boot by `src/data/generate.ts`. Anything you create lasts for the life of the dev server and resets on restart. That is deliberate — see [`CLAUDE.md`](./CLAUDE.md). ## The rules of this codebase @@ -41,7 +41,7 @@ Read [`CLAUDE.md`](./CLAUDE.md) before writing code. The short version: | `src/app/` | The console routes: overview, payments, disputes, payouts | | `src/app/api/` | Route handlers. The query builder behind `GET /api/payments` is the one to reuse | | `src/components/` | Tremor-based UI primitives and the console's own components | -| `src/data/` | Seed JSON, the in-memory store, and types | +| `src/data/` | Seed generator, the in-memory store, the query builder, and types | | `src/lib/` | Money, date, and CSV helpers, each with a `.test.ts` beside it | ## Your ticket