diff --git a/frontend/src/lib/grant-export.test.ts b/frontend/src/lib/grant-export.test.ts new file mode 100644 index 0000000..0267198 --- /dev/null +++ b/frontend/src/lib/grant-export.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; + +import { + EXPORT_AUTHORIZED_ROLES, + GRANT_EXPORT_FIELDS, + GRANT_EXPORT_HEADERS, + RESTRICTED_GRANT_FIELDS, + buildExportFilename, + canExportGrants, + escapeCsvCell, + exportGrantsToCsv, + toExportRow, +} from "./grant-export"; +import type { ExportRequester, GrantRecord } from "@/types/grant"; + +const ADDRESS = `G${"A".repeat(55)}`; + +function makeGrant(overrides: Partial = {}): GrantRecord { + return { + id: "grant-1", + title: "Community Tooling Grant", + recipientAddress: ADDRESS, + recipientName: "Ada Lovelace", + amount: 5000, + currency: "USDC", + status: "approved", + category: "tooling", + createdAt: new Date("2026-01-15T10:00:00.000Z"), + updatedAt: new Date("2026-02-01T12:30:00.000Z"), + // Restricted — must never reach the CSV. + applicantEmail: "ada@example.test", + reviewerNotes: "Strong applicant, weak budget breakdown", + internalScore: 87, + kycReference: "KYC-99812", + bankAccountNumber: "12345678", + ...overrides, + }; +} + +const admin: ExportRequester = { id: "u1", role: "admin" }; +const manager: ExportRequester = { id: "u2", role: "grant_manager" }; +const reviewer: ExportRequester = { id: "u3", role: "reviewer" }; +const contributor: ExportRequester = { id: "u4", role: "contributor" }; + +describe("grant export — authorization", () => { + it("allows admins and grant managers", () => { + expect(canExportGrants(admin)).toBe(true); + expect(canExportGrants(manager)).toBe(true); + }); + + it("refuses reviewers and contributors", () => { + expect(canExportGrants(reviewer)).toBe(false); + expect(canExportGrants(contributor)).toBe(false); + }); + + it("refuses a missing requester", () => { + expect(canExportGrants(null)).toBe(false); + expect(canExportGrants(undefined)).toBe(false); + }); + + it("refuses the export itself, not just the check", () => { + const result = exportGrantsToCsv([makeGrant()], reviewer); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unauthorized"); + }); + + it("distinguishes refusal from an empty result set", () => { + // An empty CSV would read as "there are no grants", which is a different + // and misleading answer for someone who is not allowed to ask. + const refused = exportGrantsToCsv([makeGrant()], contributor); + const empty = exportGrantsToCsv([], admin); + + expect(refused.ok).toBe(false); + expect(empty.ok).toBe(true); + if (empty.ok) expect(empty.rowCount).toBe(0); + }); + + it("only admin and grant_manager are authorized roles", () => { + expect([...EXPORT_AUTHORIZED_ROLES].sort()).toEqual(["admin", "grant_manager"]); + }); +}); + +describe("grant export — restricted fields", () => { + it("excludes every restricted field from the CSV", () => { + const result = exportGrantsToCsv([makeGrant()], admin); + expect(result.ok).toBe(true); + if (!result.ok) return; + + for (const field of RESTRICTED_GRANT_FIELDS) { + expect(GRANT_EXPORT_FIELDS).not.toContain(field); + } + + // And their values are absent from the output, not merely their names. + expect(result.csv).not.toContain("ada@example.test"); + expect(result.csv).not.toContain("weak budget breakdown"); + expect(result.csv).not.toContain("KYC-99812"); + expect(result.csv).not.toContain("12345678"); + expect(result.csv).not.toContain("87"); + }); + + it("exports exactly the declared field list, in order", () => { + const result = exportGrantsToCsv([makeGrant()], admin); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const header = result.csv.split("\r\n")[0]; + expect(header).toBe( + GRANT_EXPORT_FIELDS.map((f) => GRANT_EXPORT_HEADERS[f]).join(","), + ); + expect(result.fields).toEqual(GRANT_EXPORT_FIELDS); + }); + + it("still excludes restricted fields when they are the only populated ones", () => { + const grant = makeGrant({ reviewerNotes: "secret", applicantEmail: "x@y.test" }); + const row = toExportRow(grant); + expect(row).toHaveLength(GRANT_EXPORT_FIELDS.length); + expect(row.join(",")).not.toContain("secret"); + }); +}); + +describe("grant export — CSV correctness", () => { + it("emits a header plus one row per grant", () => { + const result = exportGrantsToCsv([makeGrant(), makeGrant({ id: "grant-2" })], admin); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.csv.split("\r\n")).toHaveLength(3); + expect(result.rowCount).toBe(2); + }); + + it("omits the header on request", () => { + const result = exportGrantsToCsv([makeGrant()], admin, { includeHeader: false }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.csv.split("\r\n")).toHaveLength(1); + }); + + it("serializes dates as ISO strings", () => { + const result = exportGrantsToCsv([makeGrant()], admin); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.csv).toContain("2026-01-15T10:00:00.000Z"); + }); + + it("quotes and escapes commas, quotes and newlines", () => { + expect(escapeCsvCell("a,b")).toBe('"a,b"'); + expect(escapeCsvCell('say "hi"')).toBe('"say ""hi"""'); + expect(escapeCsvCell("line1\nline2")).toBe('"line1\nline2"'); + }); + + it("renders null and undefined as empty cells", () => { + expect(escapeCsvCell(null)).toBe(""); + expect(escapeCsvCell(undefined)).toBe(""); + }); + + it("keeps a comma in a title from breaking the row", () => { + const result = exportGrantsToCsv( + [makeGrant({ title: "Tooling, Docs and Testing" })], + admin, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const dataRow = result.csv.split("\r\n")[1]; + expect(dataRow).toContain('"Tooling, Docs and Testing"'); + // Still exactly one row — the comma did not split it. + expect(result.csv.split("\r\n")).toHaveLength(2); + }); + + it("neutralizes spreadsheet formula injection", () => { + // A grant title is attacker-controllable; spreadsheet software would treat + // a leading = as a formula and execute it when an admin opens the export. + expect(escapeCsvCell("=1+1")).toBe("'=1+1"); + expect(escapeCsvCell("+SUM(A1)")).toBe("'+SUM(A1)"); + expect(escapeCsvCell("-2+3")).toBe("'-2+3"); + expect(escapeCsvCell("@cmd")).toBe("'@cmd"); + }); + + it("neutralizes a formula inside an exported grant title", () => { + const result = exportGrantsToCsv( + [makeGrant({ title: '=HYPERLINK("http://evil.test")' })], + admin, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.csv).toContain("'=HYPERLINK"); + }); + + it("builds a dated filename", () => { + expect(buildExportFilename(new Date("2026-08-30T09:00:00.000Z"))).toBe( + "grants-export-2026-08-30.csv", + ); + }); +}); diff --git a/frontend/src/lib/grant-export.ts b/frontend/src/lib/grant-export.ts new file mode 100644 index 0000000..5127111 --- /dev/null +++ b/frontend/src/lib/grant-export.ts @@ -0,0 +1,151 @@ +/** + * Grant record CSV export. + * + * Two things this module is responsible for, beyond producing a file: + * + * * **Only authorized callers get data.** Export is gated on role, and an + * unauthorized request returns a refusal rather than an empty file — an + * empty CSV is indistinguishable from "there were no grants", which is a + * bad answer to give someone who is not allowed to ask. + * + * * **Restricted fields never leave.** The exported columns are an explicit + * allowlist, not a denylist. A new field added to `GrantRecord` is + * therefore excluded by default: forgetting to update this file omits a + * column, whereas a denylist would leak it. + */ +import type { ExportRequester, ExportRole, GrantRecord } from "@/types/grant"; + +/** + * The exact columns an export contains, in order. + * + * Allowlist by design — see the module note above. Restricted fields + * (`applicantEmail`, `reviewerNotes`, `internalScore`, `kycReference`, + * `bankAccountNumber`) are absent and must stay absent. + */ +export const GRANT_EXPORT_FIELDS = [ + "id", + "title", + "recipientName", + "recipientAddress", + "amount", + "currency", + "status", + "category", + "createdAt", + "updatedAt", +] as const; + +export type GrantExportField = (typeof GRANT_EXPORT_FIELDS)[number]; + +/** Human-readable header for each exported column. */ +export const GRANT_EXPORT_HEADERS: Record = { + id: "Grant ID", + title: "Title", + recipientName: "Recipient Name", + recipientAddress: "Recipient Address", + amount: "Amount", + currency: "Currency", + status: "Status", + category: "Category", + createdAt: "Created At", + updatedAt: "Updated At", +}; + +/** + * Fields that must never appear in an export. Kept explicit so the guarantee + * is testable, and so a reviewer can see at a glance what is being withheld. + */ +export const RESTRICTED_GRANT_FIELDS = [ + "applicantEmail", + "reviewerNotes", + "internalScore", + "kycReference", + "bankAccountNumber", +] as const; + +/** Roles permitted to export. Reviewers and contributors are not included. */ +export const EXPORT_AUTHORIZED_ROLES: readonly ExportRole[] = ["admin", "grant_manager"]; + +export function canExportGrants(requester: ExportRequester | null | undefined): boolean { + if (!requester) return false; + return EXPORT_AUTHORIZED_ROLES.includes(requester.role); +} + +export type GrantExportResult = + | { ok: true; csv: string; rowCount: number; fields: readonly GrantExportField[] } + | { ok: false; reason: "unauthorized" }; + +/** + * Escapes one CSV cell. + * + * Beyond the usual quote/comma/newline rules, a leading `=`, `+`, `-`, `@`, + * tab or CR is prefixed with a single quote. Spreadsheet software treats those + * as the start of a formula, so an attacker-controlled grant title could + * otherwise execute when an administrator opens the export. Prefixing is the + * standard mitigation and keeps the value legible. + */ +export function escapeCsvCell(value: unknown): string { + if (value === null || value === undefined) return ""; + + let text = value instanceof Date ? value.toISOString() : String(value); + + if (/^[=+\-@\t\r]/.test(text)) { + text = `'${text}`; + } + + if (/[",\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +/** Serializes one grant to its exported cells, in `GRANT_EXPORT_FIELDS` order. */ +export function toExportRow(grant: GrantRecord): string[] { + return GRANT_EXPORT_FIELDS.map((field) => escapeCsvCell(grant[field])); +} + +export interface ExportGrantsOptions { + /** Omit the header row, e.g. when appending to an existing file. */ + includeHeader?: boolean; +} + +/** + * Builds a CSV export of `grants` for `requester`. + * + * Returns `{ ok: false, reason: "unauthorized" }` for a caller without export + * rights — deliberately distinct from a successful export of zero rows. + */ +export function exportGrantsToCsv( + grants: readonly GrantRecord[], + requester: ExportRequester | null | undefined, + options: ExportGrantsOptions = {}, +): GrantExportResult { + if (!canExportGrants(requester)) { + return { ok: false, reason: "unauthorized" }; + } + + const { includeHeader = true } = options; + + const lines: string[] = []; + if (includeHeader) { + lines.push( + GRANT_EXPORT_FIELDS.map((field) => escapeCsvCell(GRANT_EXPORT_HEADERS[field])).join(","), + ); + } + for (const grant of grants) { + lines.push(toExportRow(grant).join(",")); + } + + return { + ok: true, + // CRLF: RFC 4180, and the line ending Excel expects. + csv: lines.join("\r\n"), + rowCount: grants.length, + fields: GRANT_EXPORT_FIELDS, + }; +} + +/** Timestamped filename, e.g. `grants-export-2026-08-30.csv`. */ +export function buildExportFilename(now: Date = new Date()): string { + return `grants-export-${now.toISOString().slice(0, 10)}.csv`; +} diff --git a/frontend/src/lib/grant-import.test.ts b/frontend/src/lib/grant-import.test.ts new file mode 100644 index 0000000..4cf2af2 --- /dev/null +++ b/frontend/src/lib/grant-import.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; + +import { + findDuplicateRecipients, + formatValidationReport, + importableRows, + validateGrantImport, + type RawGrantRow, +} from "./grant-import"; + +const ADDRESS = `G${"A".repeat(55)}`; +const OTHER_ADDRESS = `G${"B".repeat(55)}`; + +function makeRow(overrides: Partial = {}): RawGrantRow { + return { + title: "Community Tooling Grant", + recipientName: "Ada Lovelace", + recipientAddress: ADDRESS, + amount: 5000, + currency: "USDC", + status: "approved", + category: "tooling", + ...overrides, + }; +} + +describe("bulk grant import — valid data", () => { + it("accepts a well-formed row", () => { + const result = validateGrantImport([makeRow()]); + + expect(result.allValid).toBe(true); + expect(result.invalid).toHaveLength(0); + expect(result.valid).toHaveLength(1); + expect(result.totalRows).toBe(1); + }); + + it("coerces a numeric amount supplied as a string", () => { + // CSV uploads deliver everything as text. + const result = validateGrantImport([makeRow({ amount: "2500" })]); + expect(result.allValid).toBe(true); + expect(result.valid[0].data.amount).toBe(2500); + }); + + it("trims surrounding whitespace", () => { + const result = validateGrantImport([ + makeRow({ recipientName: " Ada Lovelace ", category: " tooling " }), + ]); + expect(result.valid[0].data.recipientName).toBe("Ada Lovelace"); + expect(result.valid[0].data.category).toBe("tooling"); + }); + + it("returns importable rows stripped of bookkeeping", () => { + const result = validateGrantImport([makeRow(), makeRow({ recipientAddress: OTHER_ADDRESS })]); + const rows = importableRows(result); + + expect(rows).toHaveLength(2); + expect(rows[0]).not.toHaveProperty("rowNumber"); + }); + + it("reports allValid false for an empty upload", () => { + // Nothing to import is not the same as "everything passed". + const result = validateGrantImport([]); + expect(result.allValid).toBe(false); + expect(result.totalRows).toBe(0); + }); +}); + +describe("bulk grant import — invalid rows are identified", () => { + it("rejects a short title with a message naming the field", () => { + const result = validateGrantImport([makeRow({ title: "abc" })]); + + expect(result.allValid).toBe(false); + expect(result.invalid).toHaveLength(1); + expect(result.invalid[0].errors).toContainEqual({ + field: "title", + message: "title must be at least 5 characters", + }); + }); + + it("rejects a malformed recipient address", () => { + const tooShort = validateGrantImport([makeRow({ recipientAddress: "G123" })]); + expect(tooShort.invalid[0].errors[0].field).toBe("recipientAddress"); + + const wrongPrefix = validateGrantImport([ + makeRow({ recipientAddress: `X${"A".repeat(55)}` }), + ]); + expect(wrongPrefix.invalid[0].errors).toContainEqual({ + field: "recipientAddress", + message: "recipientAddress must start with 'G'", + }); + }); + + it("rejects a non-positive or non-numeric amount", () => { + expect(validateGrantImport([makeRow({ amount: 0 })]).invalid).toHaveLength(1); + expect(validateGrantImport([makeRow({ amount: -5 })]).invalid).toHaveLength(1); + expect(validateGrantImport([makeRow({ amount: "abc" })]).invalid).toHaveLength(1); + }); + + it("rejects an unknown status and lists the accepted values", () => { + const result = validateGrantImport([makeRow({ status: "pending" })]); + expect(result.invalid[0].errors[0].message).toContain("status must be one of"); + expect(result.invalid[0].errors[0].message).toContain("approved"); + }); + + it("rejects missing required fields", () => { + const result = validateGrantImport([{ title: "Only a title here" }]); + const fields = result.invalid[0].errors.map((e) => e.field); + + expect(fields).toContain("recipientName"); + expect(fields).toContain("recipientAddress"); + expect(fields).toContain("amount"); + }); + + it("reports every problem in a row, not just the first", () => { + const result = validateGrantImport([ + makeRow({ title: "no", recipientAddress: "bad", amount: -1 }), + ]); + + const fields = result.invalid[0].errors.map((e) => e.field); + expect(fields).toContain("title"); + expect(fields).toContain("recipientAddress"); + expect(fields).toContain("amount"); + }); + + it("keeps the original row for the error report", () => { + const raw = makeRow({ title: "no" }); + const result = validateGrantImport([raw]); + expect(result.invalid[0].raw).toEqual(raw); + }); +}); + +describe("bulk grant import — row numbering", () => { + it("numbers rows from 2, matching the spreadsheet under a header", () => { + const result = validateGrantImport([makeRow({ title: "no" }), makeRow()]); + expect(result.invalid[0].rowNumber).toBe(2); + expect(result.valid[0].rowNumber).toBe(3); + }); + + it("honours a custom first row number for headerless files", () => { + const result = validateGrantImport([makeRow({ title: "no" })], { firstRowNumber: 1 }); + expect(result.invalid[0].rowNumber).toBe(1); + }); + + it("numbers correctly when failures are interleaved", () => { + const result = validateGrantImport([ + makeRow(), + makeRow({ amount: -1 }), + makeRow(), + makeRow({ status: "nope" }), + ]); + + expect(result.invalid.map((r) => r.rowNumber)).toEqual([3, 5]); + expect(result.valid.map((r) => r.rowNumber)).toEqual([2, 4]); + }); +}); + +describe("bulk grant import — partial imports are safe", () => { + it("separates valid rows from invalid ones so the good data can still import", () => { + const result = validateGrantImport([ + makeRow(), + makeRow({ title: "no" }), + makeRow({ recipientAddress: OTHER_ADDRESS }), + ]); + + expect(result.totalRows).toBe(3); + expect(result.valid).toHaveLength(2); + expect(result.invalid).toHaveLength(1); + expect(result.allValid).toBe(false); + }); + + it("never lets an invalid row into the importable set", () => { + const result = validateGrantImport([ + makeRow({ title: "no" }), + makeRow({ amount: -1 }), + makeRow(), + ]); + + const rows = importableRows(result); + expect(rows).toHaveLength(1); + expect(rows[0].title).toBe("Community Tooling Grant"); + }); + + it("validates every row rather than stopping at the first failure", () => { + // One re-upload per typo would be miserable; the uploader gets the full + // list in a single pass. + const result = validateGrantImport([ + makeRow({ title: "a" }), + makeRow({ title: "b" }), + makeRow({ title: "c" }), + ]); + expect(result.invalid).toHaveLength(3); + }); +}); + +describe("bulk grant import — reporting", () => { + it("formats one readable line per problem", () => { + const result = validateGrantImport([makeRow({ title: "no", amount: -1 })]); + const report = formatValidationReport(result); + + expect(report.length).toBeGreaterThanOrEqual(2); + expect(report[0]).toMatch(/^Row 2: /); + expect(report.join("\n")).toContain("title"); + }); + + it("produces an empty report when everything passes", () => { + expect(formatValidationReport(validateGrantImport([makeRow()]))).toEqual([]); + }); + + it("flags duplicate recipients within one upload", () => { + const result = validateGrantImport([ + makeRow(), + makeRow({ recipientAddress: OTHER_ADDRESS }), + makeRow(), + ]); + + const duplicates = findDuplicateRecipients(result.valid); + expect(duplicates).toHaveLength(1); + expect(duplicates[0].recipientAddress).toBe(ADDRESS); + expect(duplicates[0].rowNumbers).toEqual([2, 4]); + }); + + it("reports no duplicates when every recipient is distinct", () => { + const result = validateGrantImport([ + makeRow(), + makeRow({ recipientAddress: OTHER_ADDRESS }), + ]); + expect(findDuplicateRecipients(result.valid)).toEqual([]); + }); +}); diff --git a/frontend/src/lib/grant-import.ts b/frontend/src/lib/grant-import.ts new file mode 100644 index 0000000..1e96890 --- /dev/null +++ b/frontend/src/lib/grant-import.ts @@ -0,0 +1,177 @@ +/** + * Bulk grant import validation. + * + * A bulk upload is usually mostly-good data with a few bad rows. Rejecting the + * whole file for one typo means the uploader fixes it, re-uploads, and hits the + * next typo — so validation here is **per row**: every row is checked, every + * failure is reported with its row number and the field that caused it, and the + * valid rows are returned ready to import. + * + * Nothing here writes anything. `validateGrantImport` is pure, so the caller + * decides whether to import the valid subset or make the uploader fix the file + * first. + */ +import { z } from "zod"; + +/** One parsed row as it arrives from a CSV/JSON upload — all values untrusted. */ +export type RawGrantRow = Record; + +export const GRANT_IMPORT_STATUSES = [ + "draft", + "submitted", + "under_review", + "approved", + "rejected", + "disbursed", +] as const; + +/** + * Row schema. + * + * Messages name the field and say what was expected, so a validation report is + * actionable without the uploader having to consult a spec. + */ +export const grantImportRowSchema = z.object({ + title: z + .string({ error: "title is required" }) + .trim() + .min(5, "title must be at least 5 characters") + .max(120, "title must be at most 120 characters"), + recipientName: z + .string({ error: "recipientName is required" }) + .trim() + .min(1, "recipientName is required"), + recipientAddress: z + .string({ error: "recipientAddress is required" }) + .trim() + .length(56, "recipientAddress must be exactly 56 characters") + .startsWith("G", "recipientAddress must start with 'G'"), + amount: z.coerce + .number({ error: "amount must be a number" }) + .positive("amount must be greater than zero"), + currency: z + .string({ error: "currency is required" }) + .trim() + .min(3, "currency must be a 3-4 character code") + .max(4, "currency must be a 3-4 character code"), + status: z.enum(GRANT_IMPORT_STATUSES, { + error: `status must be one of: ${GRANT_IMPORT_STATUSES.join(", ")}`, + }), + category: z + .string({ error: "category is required" }) + .trim() + .min(1, "category is required"), +}); + +export type ValidGrantRow = z.infer; + +export interface RowFieldError { + field: string; + message: string; +} + +export interface InvalidGrantRow { + /** 1-based row number as the uploader sees it in their file. */ + rowNumber: number; + errors: RowFieldError[]; + /** The original row, so the caller can show it back in an error report. */ + raw: RawGrantRow; +} + +export interface GrantImportValidation { + valid: { rowNumber: number; data: ValidGrantRow }[]; + invalid: InvalidGrantRow[]; + totalRows: number; + /** True when every row passed — the "safe to import everything" signal. */ + allValid: boolean; +} + +export interface ValidateGrantImportOptions { + /** + * Row number of the first data row as the uploader sees it. Defaults to 2, + * because a CSV's row 1 is the header — so reported numbers line up with what + * they see in their spreadsheet. + */ + firstRowNumber?: number; +} + +/** Flattens a ZodError into per-field messages, keeping every failure. */ +function toFieldErrors(error: z.ZodError): RowFieldError[] { + return error.issues.map((issue) => ({ + field: issue.path.length > 0 ? issue.path.join(".") : "(row)", + message: issue.message, + })); +} + +/** + * Validates every row and partitions them into importable and rejected. + * + * Every row is checked even after one fails, so the uploader gets the complete + * list of problems in one pass rather than discovering them one re-upload at a + * time. + */ +export function validateGrantImport( + rows: readonly RawGrantRow[], + options: ValidateGrantImportOptions = {}, +): GrantImportValidation { + const { firstRowNumber = 2 } = options; + + const valid: GrantImportValidation["valid"] = []; + const invalid: InvalidGrantRow[] = []; + + rows.forEach((raw, index) => { + const rowNumber = firstRowNumber + index; + const parsed = grantImportRowSchema.safeParse(raw); + + if (parsed.success) { + valid.push({ rowNumber, data: parsed.data }); + } else { + invalid.push({ rowNumber, errors: toFieldErrors(parsed.error), raw }); + } + }); + + return { + valid, + invalid, + totalRows: rows.length, + allValid: invalid.length === 0 && rows.length > 0, + }; +} + +/** + * Duplicate `recipientAddress` values *within the upload*. + * + * Not a schema rule — each row is individually valid — but importing a file + * that pays the same address twice is almost always an accident, so the + * caller is told before anything is written. + */ +export function findDuplicateRecipients( + valid: GrantImportValidation["valid"], +): { recipientAddress: string; rowNumbers: number[] }[] { + const seen = new Map(); + + for (const row of valid) { + const key = row.data.recipientAddress; + seen.set(key, [...(seen.get(key) ?? []), row.rowNumber]); + } + + return [...seen.entries()] + .filter(([, rowNumbers]) => rowNumbers.length > 1) + .map(([recipientAddress, rowNumbers]) => ({ recipientAddress, rowNumbers })); +} + +/** + * A one-line-per-problem report, ready to show the uploader. + * + * Empty array when everything passed. + */ +export function formatValidationReport(result: GrantImportValidation): string[] { + return result.invalid.flatMap((row) => + row.errors.map((error) => `Row ${row.rowNumber}: ${error.field} — ${error.message}`), + ); +} + +/** The rows safe to write, stripped of bookkeeping. */ +export function importableRows(result: GrantImportValidation): ValidGrantRow[] { + return result.valid.map((row) => row.data); +}