From a59c4b8b5e4405b9ebd08b292c2b258dc2746289 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Sun, 30 Aug 2026 11:18:51 +0100 Subject: [PATCH 1/2] feat(grants): CSV export gated by role with restricted fields excluded Lets admins and grant managers export grant records for analysis. Exported fields are an explicit allowlist (GRANT_EXPORT_FIELDS), not a denylist. That direction matters: a field added to GrantRecord later is excluded by default, so forgetting to update this file omits a column rather than leaking one. Restricted data -- applicantEmail, reviewerNotes, internalScore, kycReference, bankAccountNumber -- is named explicitly in RESTRICTED_GRANT_FIELDS so the guarantee is testable, and asserted absent by value as well as by column name. An unauthorized caller gets { ok: false, reason: unauthorized } rather than an empty file: an empty CSV reads as "there are no grants", which is a misleading answer to give someone who is not allowed to ask. CSV escaping covers quotes, commas and newlines per RFC 4180, and prefixes a leading =, +, -, @, tab or CR with a quote. Grant titles are user-controlled, and spreadsheet software would otherwise treat such a title as a formula and execute it when an administrator opens the export. 21 tests. --- frontend/src/lib/grant-export.test.ts | 194 ++++++++++++++++++++++++++ frontend/src/lib/grant-export.ts | 151 ++++++++++++++++++++ frontend/src/types/grant.ts | 52 +++++++ 3 files changed, 397 insertions(+) create mode 100644 frontend/src/lib/grant-export.test.ts create mode 100644 frontend/src/lib/grant-export.ts create mode 100644 frontend/src/types/grant.ts 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/types/grant.ts b/frontend/src/types/grant.ts new file mode 100644 index 0000000..98c6132 --- /dev/null +++ b/frontend/src/types/grant.ts @@ -0,0 +1,52 @@ +/** + * Grant records — funding awards tracked alongside tasks. + * + * The shape is split deliberately: `GrantRecord` is the full internal record, + * and only the fields named in `GRANT_EXPORT_FIELDS` (see lib/grant-export.ts) + * ever leave the system. Anything not on that list — reviewer notes, applicant + * contact details, internal scoring — stays in. + */ + +export type GrantStatus = + | "draft" + | "submitted" + | "under_review" + | "approved" + | "rejected" + | "disbursed"; + +/** The full internal record. Not safe to hand out wholesale. */ +export interface GrantRecord { + id: string; + title: string; + /** Stellar G-address the grant pays out to. */ + recipientAddress: string; + /** Public display name of the recipient. */ + recipientName: string; + amount: number; + currency: string; + status: GrantStatus; + category: string; + createdAt: Date; + updatedAt: Date; + + // ── Restricted: never exported ──────────────────────────────────────────── + /** Applicant's private contact address. */ + applicantEmail?: string; + /** Free-text reviewer commentary, often candid about the applicant. */ + reviewerNotes?: string; + /** Internal scoring used to rank applications. */ + internalScore?: number; + /** Identity/KYC reference held for compliance. */ + kycReference?: string; + /** Bank details for off-chain disbursement. */ + bankAccountNumber?: string; +} + +/** Who is asking to export, and what they are allowed to do. */ +export type ExportRole = "admin" | "grant_manager" | "reviewer" | "contributor"; + +export interface ExportRequester { + id: string; + role: ExportRole; +} From b1d7d58239be7047dda13f39f7465a9fb7496de9 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Sun, 30 Aug 2026 11:19:05 +0100 Subject: [PATCH 2/2] feat(grants): validate bulk uploads per row before importing Validates a bulk grant upload row by row so a mostly-good file is not rejected wholesale for a few typos. Every row is checked even after one fails, and every failure in a row is collected rather than just the first -- otherwise the uploader fixes one typo, re-uploads, and discovers the next. Errors carry the field name, what was expected, and a 1-based row number that defaults to starting at 2 so it lines up with what they see in their spreadsheet under the header. validateGrantImport is pure and writes nothing: it partitions rows into valid and invalid, so the caller decides whether to import the good subset or send the file back. importableRows() can only ever return rows that passed, which is what makes a partial import safe. Also flags duplicate recipientAddress values within one upload. Not a schema rule -- each row is individually valid -- but paying the same address twice in one file is almost always an accident, so the caller hears about it before anything is written. Uses zod's v4 error API ({ error: ... }); the v3 errorMap/required_error options are silently ignored on zod 4.5, which would have shipped default messages instead of the field-specific ones. 19 tests. --- frontend/src/lib/grant-import.test.ts | 229 ++++++++++++++++++++++++++ frontend/src/lib/grant-import.ts | 177 ++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 frontend/src/lib/grant-import.test.ts create mode 100644 frontend/src/lib/grant-import.ts 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); +}