Hostel students can't choose their ingredients. The mess serves what it serves, so generic calorie apps, built around cooking your own food, don't fit. MessFit starts from today's mess menu and works out how much of each dish to take to hit your calorie and macro targets, within your allergies, diet and health conditions. Then it helps you log it, train, track progress, and ask a coach that answers from vetted sources.
Built for hostellers at Amrita Vishwa Vidyapeetham, Coimbatore, by the IDEA Club.
Screens use demo data and are generated by apps/web/scripts/readme-shots.
A modular monolith: one FastAPI service with a package per domain, one async SQLAlchemy engine, and Postgres as the single store for relational data and embeddings. Every request carries the user's identity into Postgres, so row-level security enforces isolation in the database itself, not only in the API. The plate optimizer runs in the request with a Redis result cache; menu OCR and scheduled jobs go through Redis to Celery. Design notes: technical design · schema · decision records.
Deployment view
flowchart TB
subgraph Client["Student's phone / laptop"]
PWA["MessFit PWA<br/>service worker"]
end
subgraph App["Application tier"]
WEB["Next.js server<br/>SSR + auth proxy"]
API["FastAPI<br/>uvicorn workers"]
WRK["Celery worker"]
BEAT["Celery beat"]
end
subgraph Supabase["Supabase (managed)"]
AUTH["Auth · JWKS"]
PG[("Postgres 16<br/>RLS · pgvector")]
ST[("Storage<br/>menu photos")]
end
REDIS[("Redis<br/>broker · cache · limits")]
LLM["Gemini · Groq"]
PUSH["Push services<br/>FCM · Mozilla · Apple · WNS"]
OBS["OTLP collector → Grafana<br/>Sentry"]
PWA --> WEB
PWA -- "REST + SSE, Bearer JWT" --> API
PWA -- sign-in --> AUTH
API -- JWKS --> AUTH
API -- "messfit_app (RLS)" --> PG
API --> REDIS
API --> LLM
API --> ST
BEAT --> REDIS
REDIS --> WRK
WRK -- "messfit_worker" --> PG
WRK --> LLM
WRK --> ST
WRK --> PUSH
PUSH -. notification .-> PWA
API & WRK -.-> OBS
First run: sign-up to first plate
flowchart TD
A([Open MessFit]) --> B{Signed in?}
B -- no --> C[Sign up or log in<br/>email · Google · GitHub]
C --> D[Email confirmation / OAuth callback]
D --> E{Onboarded?}
B -- yes --> E
E -- no --> F[1 · Profile<br/>age, sex, height, weight]
F --> G[2 · Goal<br/>lose · maintain · gain, rate]
G --> H[3 · Diet<br/>diet type, allergies, conditions]
H --> I[4 · Hostel<br/>mess, canteen budget, equipment]
I --> J[5 · Targets<br/>calories and macros, editable]
J --> K([Today's dashboard])
E -- yes --> K
K --> L[Plate for today's menu]
The route proxy enforces this order: guests are sent to log in, signed-in users
who haven't finished onboarding are sent to it, and /admin is checked against
the database role on the server.
Daily loop
flowchart LR
M[Morning: open Plate] --> P[Optimized plate<br/>for each meal]
P --> V{Dish actually served?}
V -- no --> X[Hide it on the Menu page<br/>plate re-solves without it]
X --> P
V -- yes --> E[Eat]
P -. served today? vote .-> C[Crowd confirms the menu<br/>for other students]
E --> L[Log: as planned · different · skipped]
L --> W[Weight, mood, workout]
W --> R[Progress: trend, adherence, streak]
R --> T[Adaptive TDEE<br/>recalibrates targets]
T --> M
Admin: weekly menu from a photo
flowchart LR
U[Admin uploads<br/>menu-board photo] --> Q[Job queued]
Q --> O[Worker: vision OCR<br/>Gemini, Groq fallback]
O --> R[Review screen<br/>match dishes, fix names]
R -- approve --> A[Menu rows upserted<br/>unknown dishes drafted]
R -- reject --> J[Discarded]
A --> S[Students' plates<br/>use the new menu]
Class diagram: optimizer core (hexagonal)
The solver is a pure function over frozen dataclasses. HTTP, cache and Celery adapters sit around it, which is why 50 evaluation scenarios run with no infrastructure at all.
classDiagram
direction LR
class OptimizationInput {
<<frozen>>
+float daily_kcal
+float daily_protein_g
+float daily_carbs_g
+float daily_fats_g
+str diet_type
+tuple allergies
+tuple conditions
+str goal
+dict menu
+tuple canteen_items
+int canteen_budget_inr
+tuple skip_dish_ids
}
class Dish {
<<frozen>>
+str id
+str name
+str diet_type
+str serving_unit
+float kcal
+float protein_g
+tuple allergens
+is_integer_only() bool
}
class CanteenItem {
<<frozen>>
+str id
+str name
+int cost_inr
+float kcal
+float protein_g
}
class OptimizationOutput {
+dict plan
+dict daily_totals
+dict daily_targets
+list gap_fills
+str solver_status
+int solve_time_ms
}
class PlateItem {
+str dish_id
+float portions
+float grams
+float kcal
+str reason
}
class GapFill {
+str item_id
+float portions
+int cost_inr
+str text
}
class Solver {
<<service>>
+optimize(OptimizationInput) OptimizationOutput
+is_eligible(Dish, OptimizationInput) bool
+portion_cap(Dish, conditions) int
}
class Reasons {
<<service>>
+annotate(OptimizationOutput, OptimizationInput)
}
class ResultCache {
<<adapter>>
+get_or_optimize(redis, OptimizationInput) OptimizationOutput
}
class OptimizeRoutes {
<<adapter>>
+optimize_today()
+optimize_photo()
}
OptimizationInput "1" *-- "many" Dish
OptimizationInput "1" *-- "many" CanteenItem
OptimizationOutput "1" *-- "many" PlateItem
OptimizationOutput "1" *-- "many" GapFill
Solver ..> OptimizationInput : reads
Solver ..> OptimizationOutput : creates
Reasons ..> OptimizationOutput : explains
ResultCache --> Solver : on cache miss
OptimizeRoutes --> ResultCache
Sequence: an authenticated request under row-level security
sequenceDiagram
autonumber
participant B as Browser
participant A as FastAPI
participant K as Supabase JWKS
participant P as Postgres (messfit_app)
B->>A: GET /api/v1/logs/today · Bearer JWT
A->>A: rate limit (per route)
A->>K: public keys (cached for 1 h)
A->>A: verify ES256 signature and expiry
A->>P: set_config('request.jwt.claims', {sub})
A->>P: SELECT … FROM meal_logs
Note over P: RLS policy: user_id = auth.uid()<br/>other students' rows are invisible
P-->>A: only this student's rows
A->>P: RESET request.jwt.claims
A-->>B: 200 JSON
Sequence: building today's plate
sequenceDiagram
autonumber
participant B as Browser
participant A as Optimizer route
participant P as Postgres
participant R as Redis
participant S as MILP solver
B->>A: POST /api/v1/optimize/today
A->>P: profile, targets, today's menu, exclusions
A->>A: build OptimizationInput
A->>R: GET hash(input)
alt cache hit
R-->>A: cached plate
else miss (or Redis down)
A->>S: optimize(input)
S-->>A: plate + totals
A->>R: SETEX hash(input)
end
A->>A: annotate reasons, gap fills
A-->>B: plan · totals · targets · solve time
Sequence: how the coach answers
sequenceDiagram
autonumber
participant U as Student
participant API as FastAPI
participant DB as Postgres + pgvector
participant LLM as Gemini (Groq fallback)
U->>API: question (Bearer JWT, rate-limited)
API->>LLM: embed the question
API->>DB: semantic cache lookup (non-personal questions)
API->>DB: nearest article chunks
API->>API: medical-advice pre-check
API->>LLM: grounded prompt
LLM-->>API: tokens
API-->>U: SSE stream + citations
API->>DB: save message, cache answer
State machines: OCR job and account lifecycle
stateDiagram-v2
direction LR
[*] --> pending: admin uploads photo
pending --> processing: worker picks up
processing --> ready_for_review: parsed and validated
processing --> failed: both models failed
ready_for_review --> approved: admin approves
ready_for_review --> rejected: admin rejects
failed --> approved: admin enters menu by hand
failed --> rejected
approved --> [*]
rejected --> [*]
stateDiagram-v2
direction LR
[*] --> active: sign-up
active --> onboarded: finishes onboarding
onboarded --> pending_deletion: deletes account (access blocked)
pending_deletion --> erased: nightly sweep after 30 days
erased --> [*]
Postgres 16 on Supabase, managed by 19 reversible Alembic migrations. Conventions:
- Row-level security on all 23 tables. Personal rows are visible only to their owner
(
user_id = auth.uid()); shared catalogues (dishes, menus, articles) are readable by any signed-in user and writable only by admins. - Erasure by cascade. Every personal table references
userswithON DELETE CASCADE, so deleting a user erases all of their data in one statement. - Natural keys where the domain has them: one weight per user per day, one vote per dish per meal, one menu row per mess, date, meal and dish.
- Enumerations as CHECK constraints (for example
meal_type,diet_type,ocr_jobs.status). - Embeddings in-database:
kb_chunks.embeddingandchat_cache.query_embeddingare pgvector columns; dish search usespg_trgm.
1 · Identity, profile and settings
erDiagram
users ||--o| profiles : "has"
users ||--o| hostel_contexts : "lives in"
messes ||--o{ hostel_contexts : "serves"
users ||--o| notification_preferences : "sets"
users ||--o{ push_subscriptions : "registers"
users {
uuid id PK "= auth.users.id"
text email UK
text display_name
text role "user | admin"
timestamptz onboarded_at
timestamptz deleted_at "soft delete"
}
profiles {
uuid user_id PK, FK
date dob
text sex
numeric height_cm
numeric current_weight_kg
numeric target_weight_kg
numeric target_rate_kg_per_week
text goal "lose | maintain | gain"
int activity_level
text diet_type "veg | eggetarian | non_veg | jain"
text_array allergies
text_array conditions
}
hostel_contexts {
uuid user_id PK, FK
uuid mess_id FK
text canteen_freq
int top_up_budget_inr_weekly
text_array equipment
int workout_days_per_week
}
messes {
uuid id PK
text name UK "with college"
text college
text city
}
notification_preferences {
uuid user_id PK, FK
bool weekly_checkin
}
push_subscriptions {
uuid id PK
uuid user_id FK
text endpoint UK
text p256dh
text auth
}
2 · Daily tracking and analytics
erDiagram
users ||--o{ meal_logs : "logs"
users ||--o{ weight_logs : "logs"
users ||--o{ subjective_logs : "logs"
users ||--o{ workout_logs : "logs"
workout_templates ||--o{ workout_logs : "followed in"
users ||--o{ analytics_events : "emits"
meal_logs {
uuid id PK
uuid user_id FK
date date UK "with user, meal"
text meal_type
text status "as_planned | different | skipped"
numeric kcal
numeric protein_g
}
weight_logs {
uuid user_id PK, FK
date date PK
numeric weight_kg
}
subjective_logs {
uuid user_id PK, FK
date date PK
int energy
int hunger
int mood
}
workout_logs {
uuid id PK
uuid user_id FK
date date
text template_id FK
jsonb exercises_done
text status "done | partial | skipped"
}
workout_templates {
text id PK
text name
text goal
text_array equipment_required
jsonb structure "weeks, days, exercise ids"
}
analytics_events {
bigint id PK
uuid user_id FK
text name
jsonb props
timestamptz occurred_at "pruned after 180 days"
}
3 · Mess, menus and OCR
erDiagram
messes ||--o{ mess_menus : "publishes"
dishes ||--o{ mess_menus : "appears in"
messes ||--o{ ocr_jobs : "menu photos"
users |o--o{ ocr_jobs : "uploads"
users ||--o{ dish_exclusions : "hides"
dishes ||--o{ dish_exclusions : "hidden"
users ||--o{ dish_feedback : "votes"
dishes ||--o{ dish_feedback : "voted on"
dishes {
uuid id PK
text name UK "with serving unit"
text category
text diet_type "vegan | veg | egg | non_veg"
text default_serving_unit
numeric kcal
numeric protein_g
numeric carbs_g
numeric fats_g
text_array allergens
text portion_icon
text confidence "verified | estimated | user_reported"
}
mess_menus {
uuid id PK
uuid mess_id FK
uuid dish_id FK
date effective_from
int day_of_week
text meal_type
text availability "always | usually | sometimes"
}
ocr_jobs {
uuid id PK
uuid mess_id FK
uuid uploaded_by FK
text photo_url
text status
jsonb parsed_result
}
dish_exclusions {
uuid user_id PK, FK
date date PK
text meal_type PK
uuid dish_id PK, FK
}
dish_feedback {
uuid user_id PK, FK
date date PK
text meal_type PK
uuid dish_id PK, FK
text vote "confirm | deny"
}
4 · AI coach and knowledge base
erDiagram
users ||--o{ chatbot_conversations : "starts"
chatbot_conversations ||--o{ chatbot_messages : "contains"
kb_documents ||--o{ kb_chunks : "split into"
chatbot_conversations {
uuid id PK
uuid user_id FK
text title
}
chatbot_messages {
uuid id PK
uuid conversation_id FK
text role "user | assistant | system"
text content
jsonb citations
text model
}
kb_documents {
uuid id PK
text source
text title
}
kb_chunks {
uuid id PK
uuid document_id FK
text content
vector embedding
}
chat_cache {
uuid id PK
vector query_embedding
text response
jsonb citations
}
chat_cache is keyed by embedding similarity, not by a foreign key, and
exercises is a standalone catalogue referenced by id from
workout_templates.structure. Column-level notes:
docs/02-tdd/SCHEMA.md.
47 endpoints across 11 routers, all documented by OpenAPI at http://localhost:8000/docs when the API is running.
| Area | Prefix | Highlights |
|---|---|---|
| Profile and targets | /api/v1/profile |
profile, hostel context, computed targets |
| Optimizer | /api/v1/optimize |
today, photo |
| Tracking | /api/v1/logs |
meals, weight, subjective, workout, progress, leaderboard |
| Workouts | /api/v1/workouts |
today's session, templates |
| Coach | /api/v1/chat |
conversations, messages (SSE) |
| Notifications | /api/v1/notifications |
subscribe, unsubscribe, preferences |
| Analytics | /api/v1/analytics |
event ingest, admin summary |
| Account | /api/v1/account |
DPDP deletion |
| Mess | /mess |
messes, dishes, daily menu, exclusions, dish feedback |
| Admin | /mess/admin, /mess/admin/ocr |
mess, dish and menu management; OCR review |
| Decision | Choice | Record |
|---|---|---|
| Client | Next.js PWA: one codebase, no app store | ADR-004 |
| Service shape | FastAPI modular monolith | ADR-007 |
| Auth | Supabase Auth, ES256 JWTs via JWKS | ADR-003 |
| Optimizer | PuLP / CBC linear MILP | ADR-002 |
| Menu input | Vision OCR with human review | ADR-008 |
| Repo and tooling | Monorepo with uv and pnpm workspaces | ADR-001 · ADR-005 · ADR-006 |
Prerequisites: Node 24 LTS (pnpm 11 needs at least 22.13) with pnpm 11, Python 3.12 with uv, and Docker.
git clone https://github.com/IDEA-Amrita/Mess-Fit.git
cd Mess-Fit
# Dependencies
corepack enable && corepack prepare pnpm@11 --activate
pnpm install
cd services/api && uv sync && cd ../..
# Environment (then fill in your own keys)
cp services/api/.env.example services/api/.env
cp apps/web/.env.local.example apps/web/.env.local
# Local Postgres + Redis, then migrate
docker compose up -d
cd services/api && uv run alembic upgrade head && cd ../..Run it in three terminals:
# 1 · API on :8000
cd services/api && uv run uvicorn messfit_api.main:app --reload --port 8000
# 2 · Web on :3000
cd apps/web && pnpm dev
# 3 · Worker + scheduler (menu OCR, scheduled jobs), optional
cd services/api && uv run celery -A messfit_api.celery_app worker --beat --loglevel=infoOr run the backend from the production image instead of terminals 1 and 3:
docker compose --profile app up -d --build starts the API, worker and beat,
configured by services/api/.env (see the
deploy runbook).
Open http://localhost:3000. The API is healthy when
curl http://localhost:8000/health returns {"status":"ok", ...}.
To load the coach's knowledge base, run uv run python scripts/ingest_articles.py
from services/api; the mess, dish and workout seed scripts are alongside it.
Configuration
Secrets live in git-ignored .env files; production uses host environment
variables. Full lists: services/api/.env.example
and apps/web/.env.local.example.
| Group | Variables |
|---|---|
| Core | DATABASE_URL, REDIS_URL, SUPABASE_*, GEMINI_API_KEY, GROQ_API_KEY |
| Observability (no-op when empty) | OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN |
| Rate limiting | RATE_LIMIT_ENABLED, RATE_LIMIT_STORAGE_URI (the Redis URL in production) |
Every pull request runs the full pipeline in GitHub Actions:
| Stage | What runs |
|---|---|
| API | ruff lint and format · mypy · migrations built from scratch, reversed and rebuilt · 591 pytest tests as the RLS-restricted app role, including a 401 check on every protected endpoint · 50-scenario optimizer evaluation · pip-audit |
| Image | production container built, started and required to report healthy as a non-root user · Trivy scan fails on fixable HIGH/CRITICAL vulnerabilities |
| Web | TypeScript · ESLint · production build · 103 Playwright end-to-end tests · pnpm audit |
| Load | k6 scripts for the optimizer, chat and a smoke run (infra/load-tests) |
The backend suite runs on its own throwaway Postgres and refuses to run against
Supabase. scripts/test-db.ps1 starts one in Docker with the image CI uses:
cd services/api
./scripts/test-db.ps1 # add -Reset for a fresh database
$env:TEST_DATABASE_URL = "postgresql+asyncpg://messfit_app:messfit_app@127.0.0.1:55432/messfit_test"
$env:TEST_CELERY_DATABASE_URL = "postgresql+asyncpg://messfit_worker:messfit_worker@127.0.0.1:55432/messfit_test"
uv run ruff check . ; uv run mypy messfit_api ; uv run pytest -q ; uv run pytest eval/ -q
cd ../../apps/web
pnpm exec tsc --noEmit ; pnpm exec eslint . ; pnpm build ; pnpm exec playwright test| Control | How |
|---|---|
| Data isolation | Row-level security on every table; the API connects as a least-privilege role that cannot bypass it; Supabase's public Data API roles have no table access |
| Identity | Supabase JWTs verified against the project's JWKS on every request; admin rights come from the database, and nobody can promote themselves |
| Input handling | Image uploads allow-listed by type, capped at 10 MB and checked by magic bytes; push endpoints must be HTTPS on known push services |
| Abuse | Per-route rate limits, Redis-backed in production |
| Data rights | Account deletion with a 30-day grace period, then a nightly hard delete (India's DPDP Act); analytics are opt-out, honour Global Privacy Control and expire after 180 days |
| Supply chain | pip-audit and pnpm audit on every pull request; lockfiles committed and installed frozen |
| Operations | Runbooks for secret rotation, incident response, backup and restore, deploy and rollback and scaling |
Mess-Fit/
├── apps/web/ Next.js 16 PWA (student app + admin console)
│ ├── src/content/articles/ 15 /learn articles, also the coach's knowledge base
│ └── tests/ Playwright end-to-end suite
├── services/api/ FastAPI modular monolith
│ ├── messfit_api/ profile · mess · optimizer · tracking · workouts · chatbot
│ │ notifications · analytics · account · observability
│ ├── infra/migrations/ Alembic migrations (raw SQL, RLS policies)
│ ├── scripts/ seed, ingest and test-database scripts
│ ├── eval/ optimizer and chatbot evaluation harness
│ └── tests/ pytest suite
├── infra/
│ ├── grafana/dashboards/ API health (RED), business KPIs, AI and optimizer
│ └── load-tests/ k6 scripts
├── docs/ PRD, technical design, ADRs, runbooks, README assets
└── docker-compose.yml local Postgres + Redis
| Document | Covers |
|---|---|
| Product requirements | Who it's for, the problem, scope |
| Technical design · Schema | Architecture and data model |
| Decision records | Why each major choice was made |
| Runbooks | Backup and restore, incidents, secrets, deploys, scaling |
- Phases 0–9: core app, optimizer, menu OCR, workouts, logging, coach, content, hardening
- CI restored with a real database, RLS-enforced tests and dependency audits
- Phase 10: Amrita pilot, including deployment, nutritionist sign-off of the optimizer and a fact-check of the articles
- Phase 11: post-pilot iteration
- Branch from
mainasfeat/…,fix/…,docs/…orchore/…. - Use Conventional Commits, one logical change per commit.
- Open a pull request to
main. CI must be green before merge.
Interface icons: Lucide (ISC). Technology logos: techicons.dev (Devicon, MIT) and Simple Icons (CC0). Brand names and logos belong to their owners.







