Feat/grant export and bulk import - #178
Merged
Abd-Standard merged 3 commits intoAug 30, 2026
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds grant record CSV export and bulk-import validation.
Closes #161
Note: no grant domain existed in the repo yet — this introduces
GrantRecordand the two libraries, following the existingsrc/lib+ zod + colocated-vitest conventions.Files
New
frontend/src/types/grant.ts—GrantRecord,GrantStatus,ExportRequesterfrontend/src/lib/grant-export.ts+ tests — CSV exportfrontend/src/lib/grant-import.ts+ tests — bulk upload validationNo existing files modified.
Grant export
Exported fields are an allowlist, not a denylist.
GRANT_EXPORT_FIELDSnames the exact columns, in order. The direction matters: a field added toGrantRecordlater is excluded by default, so forgetting to update this file omits a column rather than leaking one.Restricted data never leaves.
applicantEmail,reviewerNotes,internalScore,kycReferenceandbankAccountNumberare listed explicitly inRESTRICTED_GRANT_FIELDSso the guarantee is testable, and the tests assert their values are absent from the output — not just their column names.Refusal is distinct from an empty result. An unauthorized caller gets
{ ok: false, reason: "unauthorized" }, not an empty file. An empty CSV reads as "there are no grants", which is a misleading answer for someone who is not allowed to ask.Formula injection is neutralized. Beyond RFC 4180 quote/comma/newline escaping, a cell starting
=,+,-,@, tab or CR is prefixed 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.Export is gated to
adminandgrant_manager; reviewers and contributors are refused.Bulk import validation
Per row, not per file. A bulk upload is usually mostly-good data with a few typos. Every row is validated even after one fails, and every error within a row is collected rather than just the first — otherwise the uploader fixes one problem, re-uploads, and discovers the next.
Errors are actionable. Each carries the field name, what was expected, and a 1-based row number that defaults to starting at 2, so it lines up with what the uploader sees in their spreadsheet under the header.
formatValidationReport()renders one line per problem.Partial imports are safe by construction.
validateGrantImportis 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.Duplicate recipients are flagged.
findDuplicateRecipientsreports the samerecipientAddressappearing twice in one upload. Not a schema rule — each row is individually valid — but paying one address twice in a single file is almost always an accident, so the caller hears about it before anything is written.Verification
tsc --noEmitandeslintclean on all new files.Note for reviewers
The repo is on zod 4.5, where the v3
errorMap/required_error/invalid_type_erroroptions are silently ignored — no type error, no runtime warning, just default messages. My first pass used them and a test caught it; without that assertion the import would have shipped generic messages instead of the field-specific ones the issue asks for. The schema now uses v4's{ error: ... }. Worth knowing if other zod schemas in this repo were written against v3.Acceptance criteria
Export
GRANT_EXPORT_FIELDS/GRANT_EXPORT_HEADERSRESTRICTED_GRANT_FIELDS, asserted by valueImport
importableRows()returns only rows that passed