Backend for tools.seattlesolvers — an FTC parts inventory, search, and receipt-reading service for robotics teams.
This repository is the backend/API only. It is intentionally UI-free and API-first: the frontend will be built later using the Seattle Solvers design system.
Setting this up for the first time?
SETUP.mdis the ordered runbook — GitHub, Supabase, schema, verification, deployment, and the design handoff — including which steps have never been executed yet.
Start here if you are building the frontend:
openapi.jsonat the repo root is the complete contract, committed and up to date. You do not need a database, a server, or any credentials to read it. Regenerate it any time withnpm run openapi.Live API: https://tools-api-9vfr.onrender.com (Render,
oregonregion). A custom domain attools.seattlesolversis pending DNS — seeSETUP.mdPhase 7.7.Authorization: Bearer <token>on every authenticated route, token fromPOST /api/v1/auth/login.
- Team accounts — a team is created once (team number + name), and members
join it with an invite code. JWT-based auth, with
MEMBER/TEAM_ADMIN/SUPER_ADMINroles. - Parts library + search — a curated global library of standard FTC parts from major vendors (goBILDA, REV, Axon, Ferra, MelonBotics, Offset, Mata, uxcell), filterable by category and manufacturer. Teams can add their own parts and optionally submit them to the shared library for review.
- Inventory sheet — per-team quantities of parts, with CSV export.
- Receipt reading — paste an order confirmation (or upload a PDF invoice or a photo), and the line items are extracted, matched to parts, and applied to inventory once confirmed. See Receipt intake.
- Node 20+ / TypeScript, Fastify 5
- Prisma ORM over Supabase Postgres
- Supabase Storage for receipt images
- Tesseract.js + Claude vision (
@anthropic-ai/sdk) for receipts - Zod for request/response validation and OpenAPI generation
- Vitest for tests
# 1. Install
npm install
# 2. Configure — copy the example and fill in Supabase + Anthropic values
cp .env.example .env
# 3. Check the setup before running anything that depends on it
npm run db:check
# 4. Create the schema in your Supabase database
npx prisma migrate dev --name init
# 5. Seed categories, manufacturers, the super admin, and standard parts
npm run seed
# 6. Confirm, then run the dev server (http://localhost:3000)
npm run db:check
npm run devnpm run db:check is read-only and reports connection, schema, seed, and storage
problems in plain language — worth running first, because Prisma reports a typo'd
password and an unreachable IPv6-only host with the same opaque error code. Read
the connection-string notes in .env.example before filling in the URLs.
Useful scripts:
| Script | What it does |
|---|---|
npm run dev |
Watch-mode dev server |
npm test |
Vitest suite (no database required) |
npm run typecheck |
tsc --noEmit across src, tests, and tool configs |
npm run openapi |
Write openapi.json (no database required) |
npm run accuracy |
Score receipt parsing against the labelled corpus |
npm run db:check |
Diagnose Supabase connection, schema, seed, and storage |
npm run images |
Fill in missing part images from each vendor's product page |
npm run build / npm start |
Compile to dist/ and run |
npm run prisma:studio |
Browse the database |
- A Supabase project: put its connection string in
DATABASE_URL/DIRECT_URL, the API URL/keys inSUPABASE_*, and create a private Storage bucket named to matchSUPABASE_RECEIPTS_BUCKET(defaultreceipts). Private matters: receipts carry names and shipping addresses, so links are signed per request viaGET /receipts/:id/filerather than stored. - A
SUPER_ADMIN_PASSWORDof at least 12 characters.npm run seedfails rather than creating the account that can approve parts for every team with a weak or default password. Re-seeding never changes an existing admin's password. - An
ANTHROPIC_API_KEYis optional. It is only used as a fallback when no vendor parser recognises a receipt's layout. Pasted confirmations, PDF invoices, and clear photos are all handled without it.
openapi.json(committed) — the full OpenAPI 3 document. Generate a typed client from this. No backend types are exported directly; the schema is the source of truth, and CI fails if the committed file drifts from the code.GET /openapi.json— the same document, served at runtime in every environment.GET /docs— browsable Swagger UI, development only. It is not registered in production because@fastify/swagger-uidepends on@fastify/static, which currently has an unpatched path-traversal advisory (GHSA-83w8-p2f5-377r) with no fix available.
All routes are under /api/v1. Auth is a Bearer JWT from /auth/login, team
create, or team join.
| Area | Endpoint | Notes |
|---|---|---|
| Auth | POST /auth/teams |
Create team + first admin (TEAM_ADMIN), returns invite code |
POST /auth/join |
Sign up and join a team via invite code | |
POST /auth/login |
Email + password → JWT | |
GET /auth/me |
Current user + team | |
GET /auth/invite-code |
Own team's invite code | |
PATCH /auth/password |
Change password; revokes all sessions, returns a fresh token | |
| Teams | GET /teams/current |
Team detail + member count |
PATCH /teams/current |
Rename team (TEAM_ADMIN); team number is immutable |
|
GET /teams/members |
List members | |
PATCH /teams/members/:userId |
Promote/demote between MEMBER and TEAM_ADMIN |
|
DELETE /teams/members/:userId |
Remove from team (account is kept, access revoked immediately) | |
POST /teams/join |
Join with an existing account (must be teamless) | |
POST /teams/invite-code/rotate |
Issue a new code, invalidating the old one (TEAM_ADMIN) |
|
| Dashboard | GET /dashboard |
Every count a home screen needs, in one call |
| Catalog | GET /categories, GET /manufacturers |
Reference data for filters |
| Parts | GET /parts |
Search (q, category, manufacturer, scope, ownedOnly, paging) |
GET /parts/:id |
Single part | |
POST /parts |
Add a team part; submitToLibrary queues global review |
|
PATCH /parts/:id |
Edit own team's custom part | |
DELETE /parts/:id |
Delete own team's custom part (refused while stock > 0) | |
| Inventory | GET /inventory |
The team's sheet (paginated). ?lowStock=true gives the reorder list |
PUT /inventory/:partId |
Set quantity | |
POST /inventory/:partId/adjust |
Adjust by a delta | |
DELETE /inventory/:partId |
Stop tracking (distinct from quantity 0) | |
GET /inventory/export.csv |
Download CSV | |
| Receipts | POST /receipts |
Paste an order confirmation (text or HTML email). The common case |
POST /receipts/upload |
multipart (vendor + file): PDF invoice, or photo of a paper receipt |
|
GET /receipts |
List (paginated, newest first) | |
GET /receipts/:id |
Detail with parsed line items | |
GET /receipts/:id/file |
Short-lived signed link to the original file (bucket is private) | |
PATCH /receipts/:id/lines/:lineId |
Correct a parsed line/match | |
POST /receipts/:id/confirm |
Apply matched lines to inventory (idempotent per line) | |
| Admin | GET /admin/submissions |
Pending library submissions (SUPER_ADMIN) |
POST /admin/submissions/:id/approve|reject |
Review |
Errors. Every failure — including 404s, validation errors, and rate limits — uses one envelope:
{ "error": { "code": "PART_NOT_EDITABLE", "message": "…", "issues": [] } }issues is present only on VALIDATION_ERROR. One parser handles everything.
Pagination. GET /parts, GET /inventory, and GET /receipts all return the
same shape, and all accept page / pageSize:
{ "items": [], "page": 1, "pageSize": 25, "total": 0, "totalPages": 1 }Auth. Send Authorization: Bearer <token>. Tokens last JWT_EXPIRES_IN
(7 days by default) and there is no refresh flow — plan for a re-login when a
token expires. Role and team membership are re-read from the database on every
authenticated request, so a promotion, demotion, or removal takes effect
immediately rather than at token expiry. Two error codes need explicit handling:
TOKEN_REVOKED— the password changed; log in again.ACCOUNT_GONE— the account was deleted.
Part images. Every part has an imageUrl, so a catalogue never has to render a
gap. 42 of 47 point at the vendor's own product image; the other 5 carry a
self-contained placeholder SVG, because their vendor page exposes no usable image
(four uxcell belts listed on Amazon, and one REV part whose page now 404s).
A placeholder is a data: URI, so imageUrl.startsWith('data:') identifies one
if you want to style it differently — a subtle "image coming soon" treatment, say.
Do not assume every image is a remote URL. Real images will replace these as they
are sourced, with no API change.
Home screen. GET /dashboard returns counts only, in one call: parts tracked,
total units held, low-stock count, receipts awaiting review, receipts that failed,
custom parts, and — for a SUPER_ADMIN — submissions pending approval. team is
null for a caller with no team, admin null unless they are a SUPER_ADMIN.
Fetch the matching lists from /inventory, /receipts, and
/admin/submissions, which already page and filter.
Low stock. Each inventory row carries minQuantity (the level the team wants
to keep) and isLow. A minQuantity of 0 means not tracked, so the feature is
opt-in per part — without that, every part run down to zero would show as an
alert and the list would be noise. GET /inventory?lowStock=true is the reorder
view, and the natural thing to surface on a dashboard: for a competition team,
"you are down to one spare motor" is the single most actionable signal here.
Anonymous access. GET /parts, GET /parts/:id, GET /categories, and
GET /manufacturers work without a token and return the approved global library.
Authenticated callers additionally see their own team's custom parts and get an
ownedQuantity on each part.
Rate limits. Per IP, per minute: 300 globally, 10 on credential endpoints
(login, signup, join, password change), and 30 on receipt intake — the costliest
work here. Exceeding any returns 429 with code RATE_LIMITED.
Receipt review flow. Both intake routes return parsed line items with a
matchConfidence and a possibly-null matchedPart. Deliberately, an ambiguous
name match returns no match rather than a guess — vendor part names often differ
only in one spec (two goBILDA motors identical but for 19.2:1 vs 26.9:1), and
guessing silently corrupts inventory. Design for a review step: the user fixes
matches via PATCH /receipts/:id/lines/:lineId, then calls /confirm.
CORS. Set CORS_ORIGINS to the frontend origin(s), comma-separated.
openapi.json the API contract (committed, CI-verified)
prisma/schema.prisma data model
prisma/seed.ts categories, manufacturers, super admin, standard parts
prisma.config.ts Prisma config (replaces the deprecated package.json key)
scripts/export-openapi.ts writes openapi.json without booting a server
src/server.ts Fastify bootstrap, OpenAPI, security, error handling
src/config/env.ts validated environment config
src/lib/ prisma, supabase, claude, auth, errors, invite codes
src/plugins/auth.ts auth guards (requireAuth / requireRole / requireTeam / requireTeamAdmin)
src/modules/ auth, teams, catalog, parts, inventory, receipts, admin
src/services/ocr/ receipt pipeline: text/HTML/PDF extraction, vendor
parsers, tesseract, and the claude fallbacks
src/services/partMatch.ts line-item → part matching
tests/ vitest suites (no database required)
Dockerfile multi-stage production image
Nearly every FTC order is placed online, so receipts are digital by default. Digital sources carry exact characters — there is nothing to decipher, only to organise — which is why the common paths involve no OCR and usually no model call.
| Source | Route | method recorded |
Cost |
|---|---|---|---|
| Pasted confirmation text | POST /receipts |
PASTED_TEXT |
free |
| Pasted HTML email body | POST /receipts (auto-detected) |
PASTED_HTML |
free |
| Downloaded PDF invoice | POST /receipts/upload |
PDF_TEXT |
free |
| Photo of a paper receipt | POST /receipts/upload |
TESSERACT |
free |
| Digital text no parser recognises | either | CLAUDE_TEXT |
cheap — text only |
| Photo OCR could not read | POST /receipts/upload |
CLAUDE_VISION |
most expensive |
Two consequences worth designing around:
- Claude vision is now a corner case. It is reached only by a photo that defeats both Tesseract and the vendor parsers. A digital receipt never reaches it; if a digital layout is unrecognised, the fallback is Claude on text, which costs a fraction of a vision call.
- A scanned PDF is not a digital receipt. If a PDF has no text layer it is a
scan, and the API returns
PDF_NOT_DIGITALasking for a photo instead, rather than silently producing nothing.
Parsers are block-based: each item anchors on a line (a SKU, or the product name) and the following lines up to the next anchor form its block, stopping at any totals line. This is what makes one parser handle both receipt shapes — an OCR'd photo puts a whole item on one line, a digital confirmation stacks SKU, name, quantity and prices on separate lines, and both reduce to the same block.
Tuned parsers exist for goBILDA, REV, Axon, and uxcell (the last
unverified against a real receipt — see the note in uxcell.ts). Ferra,
MelonBotics, Offset, and Mata deliberately have none: their SKU formats aren't
known here, and a guessed pattern is worse than no pattern, since it would either
never match or match the wrong thing confidently. They use the generic block
parser, which needs no SKU at all.
Every tuned parser has the generic parser composed behind it, so a receipt that omits SKUs — or uses a format the pattern doesn't cover — still yields line items instead of falling through to Claude.
Adding a tuned parser is the highest-leverage work left, and needs one real order confirmation per vendor to do honestly. Vendor priority: goBILDA → REV → Axon → Ferra → MelonBotics/Offset → Mata → uxcell.
Digital receipts are held to >90% line accuracy. npm run accuracy scores the
deterministic parsers against the labelled corpus in
tests/fixtures/receipts/ and exits non-zero below the
threshold; CI runs it on every push.
The Claude fallback is excluded from the score on purpose, so the number is reproducible, free, and moves only when the parsers improve.
The corpus currently contains one real fixture and four synthetic ones. The real one (a goBILDA order confirmation) already earned its keep once — it caught a name/SKU misalignment bug that every synthetic fixture missed, because none of them modeled the real email's layout. One data point per vendor is still far too small to call the >90% bar met in general: REV, Axon, and the five untuned vendors have zero real coverage so far. Adding more real confirmations is what closes that gap; see the corpus README for the (short) process.
npm test runs 107 tests covering the vendor parsers (both single-line and
stacked digital layouts), HTML email and PDF text extraction, the part-matching
ambiguity rule, the parts visibility/tenancy filter, and the HTTP contract
(error envelope, auth rejection, request validation, rate limiting, security
headers). All of it runs without a database, which is also how CI runs it.
Manually verified against the live Supabase project (see SETUP.md Phase
5): auth, team/invite flows, inventory arithmetic, a real receipt end to end
(including the parser bug it found and the correction workflow), and
confirm-idempotency — confirming the same receipt twice does not double-apply
it.
Not yet covered by the automated suite: the same DB-dependent paths above have no integration tests exercising them against Postgres — they've only been checked by hand, once, against production data. Adding a throwaway-Postgres integration suite so this runs in CI, not just manually, is the next gap to close here.