Skip to content

Feat/grant export and bulk import - #178

Merged
Abd-Standard merged 3 commits into
Core-Foundry:mainfrom
N-thnI:feat/grant-export-and-bulk-import
Aug 30, 2026
Merged

Feat/grant export and bulk import#178
Abd-Standard merged 3 commits into
Core-Foundry:mainfrom
N-thnI:feat/grant-export-and-bulk-import

Conversation

@N-thnI

@N-thnI N-thnI commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds grant record CSV export and bulk-import validation.

Closes #161

Note: no grant domain existed in the repo yet — this introduces GrantRecord and the two libraries, following the existing src/lib + zod + colocated-vitest conventions.

Files

New

  • frontend/src/types/grant.tsGrantRecord, GrantStatus, ExportRequester
  • frontend/src/lib/grant-export.ts + tests — CSV export
  • frontend/src/lib/grant-import.ts + tests — bulk upload validation

No existing files modified.

Grant export

Exported fields are an allowlist, not a denylist. GRANT_EXPORT_FIELDS names the exact columns, in order. The 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 never leaves. applicantEmail, reviewerNotes, internalScore, kycReference and bankAccountNumber are listed explicitly in RESTRICTED_GRANT_FIELDS so 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 admin and grant_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. 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.

Duplicate recipients are flagged. findDuplicateRecipients reports the same recipientAddress appearing 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

  • 40 new tests pass (21 export, 19 import).
  • Full suite 180 → 220 passing (+40, exactly the new tests), with the same 3 pre-existing failures and 2 errors as baseline — verified by stashing and re-running. Zero regressions.
  • tsc --noEmit and eslint clean on all new files.

Note for reviewers

The repo is on zod 4.5, where the v3 errorMap / required_error / invalid_type_error options 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

  • Data can be exported in CSV format
  • Exported fields are clearly defined — GRANT_EXPORT_FIELDS / GRANT_EXPORT_HEADERS
  • Sensitive or restricted information is excluded — allowlist + RESTRICTED_GRANT_FIELDS, asserted by value

Import

  • Invalid rows are identified — with row numbers matching the uploader's spreadsheet
  • Clear validation errors are provided — field name + expectation, all problems in one pass
  • Valid records can be imported safely — importableRows() returns only rows that passed

N-thnI and others added 3 commits August 30, 2026 11:18
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.
@Abd-Standard
Abd-Standard merged commit 62161d6 into Core-Foundry:main Aug 30, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add CSV Export for Grant Data

2 participants