Take a photo of your fridge, get recipes you can actually cook with what's in it.
FridgeAI detects the ingredients in a fridge photo with a local vision model, lets you correct the list, matches it against a recipe catalogue by ingredient coverage, and enriches the results with flavour-pairing intelligence from Epicure — substitutions for the ingredients you're missing and a ranked "buy one more thing" suggestion list.
flowchart LR
A[Snap / pick a photo] --> B[Upload]
B --> C[Ollama vision model\ndetects ingredients]
C --> D[Review & correct\nthe detected list]
D --> E[Coverage-ranked\nrecipe matching]
E --> F[Epicure substitutions\n+ suggestions]
F --> G[Scan history]
A more detailed version of this flow — including the intermediate conversation states — is diagrammed
in docs/architecture/fridgeAI_processing_flow.svg.
- Photo capture and upload — native camera screen (
expo-camera) or photo library picker, client-side downscale/compress (expo-image-manipulator) before upload. - Local, private vision inference — ingredient detection runs against an Ollama vision model on your own machine; no photo ever leaves your infrastructure for detection.
- Editable ingredient review — detected ingredients are resolved against a 131-ingredient seeded
dictionary via fuzzy matching (Postgres
pg_trgm); the user can remove wrong detections and search-add missing ones before matching. - Coverage-ranked recipe matching — recipes are scored by the fraction of their ingredients present in the user's confirmed list and returned best-match first, each with its own missing-ingredient list.
- Flavour-aware substitutions — for every missing ingredient in a match, FridgeAI asks Epicure's
neighborsMCP tool for flavour-close ingredients and offers ones already in the user's list as a substitute. - "Add one ingredient" suggestions — ranks ingredients the user doesn't have by how many additional
recipes they'd unlock, using Epicure's
pairing_scoreto pick a flavour-compatible candidate. - Scan history — every conversation (scan) is persisted per user and browsable later, complete with its original photo (served via a short-lived signed URL) and its results.
- Session auth — email/password accounts via better-auth, with Expo's secure-storage cookie plugin on the client and a global session guard on the server.
- Hardened by default — every route is authenticated unless explicitly marked
@Public(), requests are rate-limited, bodies are strictly validated and size-capped, uploaded files are content-sniffed (not trusted by extension), and Postgres Row-Level Security backs every table.
| Layer | Choice | Why |
|---|---|---|
| Backend framework | NestJS 11 + Express 5 | Modular DI, guards/interceptors/pipes fit the auth + validation requirements directly. |
| Database | Postgres via Supabase (local CLI stack or hosted) | pg_trgm fuzzy search, Row-Level Security, and Storage all live in the same box. |
| ORM / migrations | Drizzle ORM + drizzle-kit |
SQL-shaped schema, typed queries, plain .sql migration files reviewed in this repo. |
| Auth | better-auth (email/password) | Owns the user/session/account tables and cookie lifecycle; no hand-rolled password storage. |
| File storage | Supabase Storage (private bucket, signed URLs) | Same project as the database; no separate object-storage credential to manage. |
| Vision inference | Ollama (qwen2.5vl:7b by default), HTTP /api/generate with a JSON schema |
Runs entirely on the host machine — no photo leaves your network for ingredient detection. |
| Flavour intelligence | Epicure MCP over the Model Context Protocol (@modelcontextprotocol/sdk) |
Public, unauthenticated computational-gastronomy service; every call is cached and budget-limited and degrades to empty results if unreachable. |
| Frontend | Expo 57 / React Native 0.86 + Expo Router | Camera, secure storage, and a single codebase for iOS/Android/web. |
| Client data layer | TanStack Query | Polling for async processing state, caching, mutation-driven invalidation. |
| Validation | Zod (env), class-validator/class-transformer (HTTP DTOs) |
Fail fast on bad config; reject unknown/malformed request bodies before they reach a service. |
- Create —
POST /api/v1/conversationscreates a row inAWAITING_PHOTOowned by the caller. - Upload —
POST /api/v1/conversations/:id/photo(multipart, capped atMAX_UPLOAD_BYTES) content-sniffs the file's magic bytes (never trusts the extension orContent-Typeheader), stores it in Supabase Storage under the user's own prefix, flips the conversation toPROCESSING, and fires the vision pipeline without blocking the response (202 Accepted). - Detect — a bounded promise queue (
VISION_MAX_CONCURRENCYconcurrent,VISION_MAX_QUEUEdeep) sends the image to Ollama with a strict JSON output schema, resolves each returned label against the ingredient dictionary (exact term → trigram fuzzy match ≥INGREDIENT_FUZZY_THRESHOLD), and moves the conversation toAWAITING_REVIEW. Labels that don't resolve to anything in the dictionary are kept asunmatchedLabelsinstead of being silently dropped. Any vision failure moves the conversation toFAILEDwith anerrorCodeinstead of hanging. - Review — the client shows the detected chips; the user can remove wrong ones and search-add
correct ones (
GET /api/v1/ingredients?search=, trigram-ranked). - Match —
PATCH /api/v1/conversations/:id/ingredientswrites the confirmed list, scores every recipe bymatched_ingredients / total_ingredients(a single coverage CTE againstrecipe_ingredients), keeps matches at or aboveRECIPE_MIN_COVERAGE, and returns the topRECIPE_MATCH_LIMITsorted by coverage. - Enrich — for each match's missing ingredients,
EpicureServiceasks the Epicure MCPneighborstool for flavour-close ingredients and keeps the ones already in the user's confirmed list as a substitution.GET /api/v1/conversations/:id/suggestionsseparately ranks ingredients the user could buy by how many additional recipes they'd unlock, using Epicure'spairing_scoreto break ties on flavour compatibility. Every Epicure call is cached inepicure_cache(EPICURE_CACHE_TTL_DAYS) and capped per-conversation (EPICURE_MAX_CALLS_PER_CONVERSATION); settingEPICURE_ENABLED=falsemakes both endpoints return empty arrays instead of failing. - History —
GET /api/v1/conversationslists the caller's own past scans, newest first, cursor-paginated.
stateDiagram-v2
[*] --> AWAITING_PHOTO
AWAITING_PHOTO --> PROCESSING
PROCESSING --> AWAITING_REVIEW
AWAITING_REVIEW --> REVIEW_ACCEPTED
AWAITING_REVIEW --> PROCESSING: retake photo
REVIEW_ACCEPTED --> AWAITING_MATCHING
AWAITING_MATCHING --> COMPLETED
AWAITING_PHOTO --> FAILED
PROCESSING --> FAILED
AWAITING_REVIEW --> FAILED
REVIEW_ACCEPTED --> FAILED
AWAITING_MATCHING --> FAILED
FAILED --> PROCESSING: retry
COMPLETED --> [*]
Every transition is checked server-side against this table (backend/src/conversations/conversation-status.ts)
before it's written; an invalid transition is rejected with 409 Conflict rather than silently applied.
Nine application tables (plus better-auth's own user/session/account/verification), all under
Row-Level Security:
| Table | Purpose |
|---|---|
ingredients |
The canonical dictionary: name (unique), category (FRESH/PANTRY/PROTEIN). |
ingredient_lookup |
Every alias/term (e.g. tomatoe, scallion) that resolves to an ingredients.id, indexed with a pg_trgm GIN index for fuzzy search. |
recipes |
name (unique), description, instructions, cooking_time, image_url, created_by (a better-auth user id, nullable for seed data). |
recipe_ingredients |
Join table: recipe_id + ingredient_id (composite PK) with amount/unit. |
conversations |
One row per scan: user_id, status (the enum above), image_path, unmatched_labels[], error_code. Indexed on (user_id, created_at desc) for history paging. |
conversation_ingredients |
The confirmed ingredient list for a conversation, tagged source = DETECTED or ADDED. |
conversation_matches |
The persisted match results: coverage, position, missing_ingredient_ids[], one row per (conversation_id, recipe_id). |
conversation_substitutions |
Epicure-derived substitutions per match, keyed to (conversation_id, recipe_id, missing_ingredient_id), FK-linked to conversation_matches. |
epicure_cache |
Generic key → jsonb payload cache for every Epicure MCP call, so repeat requests for the same conversation are instant and don't re-spend the call budget. |
Migrations are plain, reviewed .sql files under backend/drizzle/, applied with drizzle-kit migrate.
better-auth's own tables are generated separately by its CLI (auth migrate) — see
db:migrate.
All product routes are mounted under /api/v1 and require a valid better-auth session cookie unless
noted public. better-auth's own routes live under /api/auth/* (sign-up, sign-in, sign-out,
get-session, ...) and are documented by the library itself. Live Swagger docs for the whole API are
served at GET /api/v1/docs in non-production environments.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/v1/health |
public | Liveness + DB connectivity check. |
POST |
/api/v1/conversations |
session | Start a new scan (AWAITING_PHOTO). |
POST |
/api/v1/conversations/:id/photo |
session, owner | Upload the fridge photo (multipart photo field); 202 once processing has been kicked off. Rate-limited (6/min). |
GET |
/api/v1/conversations/:id |
session, owner | Full conversation detail: status, signed image URL, detected ingredients, matched recipes with coverage/missing ingredients/substitutions. |
PATCH |
/api/v1/conversations/:id/ingredients |
session, owner | Submit the reviewed ingredient list ({ ingredientIds: string[] }, 1–80 UUIDs) and run matching. |
GET |
/api/v1/conversations/:id/suggestions |
session, owner | "Add one ingredient" suggestions, each with unlockedRecipes and an Epicure affinity score. |
GET |
/api/v1/conversations |
session | Paginated scan history (?limit=&cursor=), newest first. |
GET |
/api/v1/ingredients |
session | Trigram-ranked ingredient search for the review screen's search box (?search=&limit=). |
GET |
/api/v1/recipes/:id |
session | Full recipe detail (ingredients with amount/unit, instructions). |
A request for another user's conversation returns 404, never 403 — existence of another user's data
is not disclosed.
- Auth by default. A global
SessionGuard(APP_GUARD) rejects every request without a valid better-auth session unless the route carries@Public(). OnlyGET /api/v1/healthis public. - Ownership checks, not just auth. Every conversation lookup filters by
user_idin addition toid; a mismatched owner gets404 Not Found. - Row-Level Security. All nine application tables have RLS enabled in Postgres itself — a defense
layer independent of the application code, in case a future code path (or a direct DB client using the
anon/authenticated key) forgets a
WHERE user_id = ...clause. - Strict input validation.
ValidationPiperuns globally withwhitelist: trueandforbidNonWhitelisted: true— any unexpected field in a request body is rejected outright, not silently dropped. - Uploads are content-sniffed.
image-signature.tschecks the file's actual magic bytes against JPEG/PNG/WEBP/HEIC signatures; a.txtrenamed to.jpgis rejected with415, and bodies overMAX_UPLOAD_BYTESare rejected with413before ever reaching the vision pipeline. - Private storage, signed access. The Supabase Storage bucket is private; photos are only reachable
through short-lived signed URLs (
SIGNED_URL_TTL_SECONDS) minted per-request for the owning user. - Rate limiting. A global throttle (
THROTTLE_LIMITrequests /THROTTLE_TTL_MS) plus a tighter per-route limit on photo upload guard against abuse and runaway Ollama/Epicure spend. - Hardened HTTP surface.
helmet()is applied globally,x-powered-byis disabled, CORS is closed by default (CORS_ORIGINSopt-in only), and every response carries anx-request-idfor correlating logs. - No plaintext credentials. Password hashing, session tokens, and cookie handling are entirely owned by better-auth; the application code never touches a password.
- Third-party calls can't fail the product. Every Epicure MCP call is wrapped so a timeout,
transport error, or malformed response degrades to an empty result (
[]) instead of a5xx— the core scan → review → match flow is fully independent of Epicure's availability.
fridgeAI/
├── backend/ NestJS API
│ ├── src/
│ │ ├── auth/ better-auth ESM bridge, session guard, decorators
│ │ ├── common/ request-id middleware, exception filter, image signature sniffing, promise queue
│ │ ├── config/ Zod env schema
│ │ ├── conversations/ the scan lifecycle: controller, service, processing pipeline, status machine
│ │ ├── db/ Drizzle schema + pg pool module
│ │ ├── epicure/ MCP client, cache repository, service (substitutions/suggestions)
│ │ ├── health/ liveness endpoint
│ │ ├── ingredients/ dictionary search + fuzzy resolver
│ │ ├── recipes/ recipe detail endpoint
│ │ ├── storage/ Supabase Storage wrapper (save/download/sign/remove)
│ │ ├── seed/ seed data (JSON) + seed script
│ │ └── vision/ provider abstraction + Ollama HTTP implementation
│ ├── drizzle/ reviewed SQL migrations
│ └── test/ Jest unit specs (co-located `*.spec.ts`) + e2e specs against a real test DB
├── frontend/ Expo Router app
│ ├── app/ screens: index (home), camera, processing, ingredient-review, recipe/matches,
│ │ recipe/[recipeId], history, login, register
│ ├── api/ typed fetch client, better-auth-aware request wrapper, per-resource functions
│ ├── components/ ThemedView/Text/Pressable, RecipeCard, IngredientChip, Spinner, Empty/ErrorState, ...
│ ├── providers/ Theme + TanStack Query providers
│ └── lib/ better-auth Expo client
├── supabase/ local Supabase CLI project config (`supabase start`/`stop`)
├── docs/architecture/ processing-flow diagram
└── .github/workflows/ci.yml lint + build + unit + e2e (against a real local Supabase stack) on every push/PR
- Node.js 24+ and npm 11+ (this is an npm workspaces monorepo — root,
backend/,frontend/). - Docker running (the Supabase CLI runs Postgres/Storage/Auth/Studio as containers).
- Supabase CLI — already a
root devDependency, invoked via
npx supabase/ the root npm scripts, no separate install needed. - Ollama installed on the host (not containerized — Docker Desktop on macOS has no GPU passthrough).
- Expo Go on a physical device, or an iOS Simulator / Android emulator, if you want to run the frontend on a device rather than the web preview.
git clone [email protected]:QuickerMaths/fridgeAI.git
cd fridgeAI
npm installThis installs both workspaces (backend, frontend) from the single root lockfile.
npm run db:upThis runs supabase start and prints a block of local credentials. Copy the service_role key — you'll
need it in the next step. Default local ports: API 54321, Postgres 54322, Studio 54323.
Stop it later with npm run db:down.
cp backend/.env.example backend/.envThen edit backend/.env:
SUPABASE_SERVICE_ROLE_KEY— paste theservice_role keyprinted bynpm run db:up.BETTER_AUTH_SECRET— any random string ≥ 32 characters, e.g.openssl rand -base64 32.- Everything else has a sane local default (
DATABASE_URLandSUPABASE_URLalready point at the local stack's default ports). See the configuration reference for what each variable does.
If you'd rather point at a hosted Supabase project instead of the local stack, skip npm run db:up and
paste that project's connection string / service-role key here instead — no code changes needed either way.
npm run db:migrate # applies better-auth's tables, then the Drizzle migrations
npm run seed # loads the 131-ingredient dictionary and 16 seed recipes (idempotent)ollama serve
ollama pull qwen2.5vl:7bOLLAMA_MODEL in backend/.env can point at a different vision-capable model (e.g. llava:13b) if
qwen2.5vl:7b is too slow for your hardware — no code change needed, the JSON-schema output contract is
model-independent.
npm run dev:backendStarts NestJS in watch mode on http://localhost:3000. Verify it's up:
curl http://localhost:3000/api/v1/healthSwagger docs are at http://localhost:3000/api/v1/docs.
cp frontend/.env.example frontend/.envSet EXPO_PUBLIC_API_URL in frontend/.env. On a physical device this must be your machine's LAN
IP, not localhost (the phone can't resolve your laptop's localhost), e.g.
EXPO_PUBLIC_API_URL=http://192.168.1.20:3000. For the web preview or a simulator on the same machine,
http://localhost:3000 works.
If you changed the LAN IP, also add it to CORS_ORIGINS in backend/.env if you're testing the Expo
web target in a browser (native clients aren't subject to CORS).
npm run dev:frontendThis runs expo start. Press i for the iOS simulator, a for Android, w for web, or scan the QR
code with Expo Go on a physical device.
Register an account, land on the home screen, tap the scan button, take (or pick) a photo of a fridge, wait for the processing spinner, review/correct the detected ingredients, tap "Find recipes", open a match, and check "View scan history" afterwards to see it saved.
All backend configuration is validated at boot with Zod (backend/src/config/env.schema.ts); a missing
or malformed value fails startup immediately with the offending key named, rather than failing later at
request time.
| Variable | Default | Notes |
|---|---|---|
NODE_ENV |
development |
development | test | production. Also gates Swagger (/api/v1/docs). |
PORT |
3000 |
HTTP port. |
DATABASE_URL |
— (required) | Postgres connection string, direct (non-pooled) connection. |
SUPABASE_URL |
— (required) | Supabase project API URL. |
SUPABASE_SERVICE_ROLE_KEY |
— (required) | Service-role key; server-side only, never exposed to the client. |
SUPABASE_STORAGE_BUCKET |
fridge-photos |
Private bucket for uploaded photos. |
SIGNED_URL_TTL_SECONDS |
3600 |
Lifetime of a signed photo URL. |
BETTER_AUTH_SECRET |
— (required, ≥32 chars) | Signs session tokens/cookies. |
BETTER_AUTH_URL |
http://localhost:3000 |
Public origin better-auth issues cookies/links for. |
APP_SCHEME |
fridgeai |
Deep-link scheme the Expo app registers for auth redirects. |
CORS_ORIGINS |
(empty → CORS closed) | Comma-separated allow-list; only needed for browser-based (Expo web) clients. |
MAX_UPLOAD_BYTES |
8388608 (8 MiB) |
Hard cap on the photo upload body. |
OLLAMA_BASE_URL |
http://127.0.0.1:11434 |
Ollama server. |
OLLAMA_MODEL |
qwen2.5vl:7b |
Any vision-capable Ollama model. |
OLLAMA_TIMEOUT_MS |
120000 |
Per-request timeout before a scan is marked FAILED. |
VISION_MAX_CONCURRENCY |
1 |
Concurrent in-flight vision requests. |
VISION_MAX_QUEUE |
20 |
Queued-but-not-yet-running requests before new ones are rejected. |
INGREDIENT_FUZZY_THRESHOLD |
0.45 |
Minimum pg_trgm similarity to accept a fuzzy ingredient match. |
RECIPE_MIN_COVERAGE |
0.6 |
Minimum ingredient coverage for a recipe to be considered a match. |
RECIPE_MATCH_LIMIT |
20 |
Max matches returned per conversation. |
EPICURE_ENABLED |
true |
Set false to disable all Epicure calls; substitutions/suggestions become []. |
EPICURE_MCP_URL |
https://epicure-mcp.kaikaku.ai/mcp |
Public Epicure MCP endpoint. |
EPICURE_TIMEOUT_MS |
8000 |
Per-call timeout. |
EPICURE_CACHE_TTL_DAYS |
30 |
How long a cached Epicure response is reused. |
EPICURE_MAX_CALLS_PER_CONVERSATION |
12 |
Upper bound on Epicure calls spent enriching one conversation. |
EPICURE_MIN_SIMILARITY |
0.3 |
Minimum flavour-similarity to surface a neighbor/substitution. |
THROTTLE_TTL_MS / THROTTLE_LIMIT |
60000 / 120 |
Global rate limit window/count. |
Frontend: EXPO_PUBLIC_API_URL (see step 7) is the only required
variable.
npm run lint:backend # ESLint, backend
npm run build:backend # nest build (also a typecheck)
npm run test:backend # Jest unit tests (co-located *.spec.ts)
npm run test:e2e:backend # Jest e2e against a real, migrated `fridgeai_test` Postgres database
npm run lint:frontend # ESLint (eslint-config-expo), frontend
npm run typecheck:frontend # tsc --noEmit, frontendtest:e2e:backend creates/migrates a dedicated fridgeai_test database and a fridge-photos-test
storage bucket against your running local Supabase stack the first time it's run (pretest:e2e), stubs
out VisionProvider and the Epicure MCP client so no test call ever reaches Ollama or the public
internet, and truncates the conversation-scoped tables between tests. It intentionally runs single-worker
(--runInBand): two Jest workers issuing concurrent TRUNCATE ... RESTART IDENTITY CASCADE against the
same database reliably deadlock in Postgres.
.github/workflows/ci.yml runs this entire matrix on every push/PR against a real Supabase stack spun up
in the CI runner (supabase start), with EPICURE_ENABLED=false so CI never depends on the public
Epicure endpoint.
DATABASE_URL is required(or similar) on boot — a required env var is missing/invalid; the error names the exact key. Checkbackend/.envagainstbackend/.env.example.ECONNREFUSEDto Ollama, scan ends inFAILED—ollama serveisn't running, orOLLAMA_MODELisn't pulled (ollama pull qwen2.5vl:7b). The app surfaces a "Try again" action; it never hangs. Detection is fully local — nothing is sent to Epicure or any other external service during this step.- Physical device can't reach the backend —
EXPO_PUBLIC_API_URLmust be your machine's LAN IP, notlocalhost; make sure the phone and computer are on the same network and the backend'sCORS_ORIGINS(if you're on Expo web) includes that origin. - Epicure substitutions/suggestions are empty — either
EPICURE_ENABLED=false, or the public endpoint is unreachable/rate-limiting; both degrade gracefully by design (see Security). The core scan → review → match flow is unaffected either way. 401on every request — the session cookie didn't round-trip; confirm you're signed in (authClient.useSession()client-side) and thatBETTER_AUTH_URL/CORS_ORIGINSmatch the origin the request is actually coming from.