A nutrition and health companion that removes the friction from food logging, built as a university DevOps course project (org AET-DevOps26, repo team-password123). Snap a photo of a meal, get calories and macros back instantly via GenAI, and track long-term trends in an analytics dashboard.
- Photo → nutrition. Upload a meal photo; the GenAI service (vision LLM) returns identified foods, calories, and macros (protein/carbs/fat/fiber) with a confidence score.
- Manual logging. Add meals and macros by hand; edit and delete entries.
- Diary. Per-day food log with day-to-day navigation.
- Analytics. Daily and weekly aggregates with goal deltas and a logging streak.
- Goals. Per-user nutrition goals (calories + macros), with Mifflin–St Jeor TDEE suggestions in onboarding.
- Auth. Email/password registration and login with RS256 JWTs: only
auth-serviceholds the RSA private key and can issue tokens; every other service verifies with the public key alone. - iOS app. SwiftUI + SwiftData client with backend sync (auth, meals, goals, GenAI photo analyze). Water tracking and widget stay local-only.
| Path | What's there |
|---|---|
calorie-app/ |
React + TypeScript web client (Vite, Feature-Sliced Design). Port 3000. |
services/auth-service/ |
Identity, registration, login, JWT issuance. Port 8081, schema auth. |
services/meals-service/ |
Manual meal logging + photo scan with GenAI analysis. Port 8082, schema meals. |
services/analytics-service/ |
Goals and daily/weekly aggregations. Port 8083, schema analytics. |
services/genai-service/ |
Python FastAPI vision service (Gemini primary in prod, OpenAI-compatible backup API). Port 8084. |
ios-app/ |
SwiftUI + SwiftData iOS client (offline cache + backend sync) |
helm/calorieasy/ |
Kubernetes Helm chart for AET cluster deployment. |
infra/ |
Terraform (Azure VM) + Ansible (Docker Compose deploy). |
docs/ |
Problem statement, system architecture, API reference, sprint plan. |
Each student owns one primary subsystem (client / server / GenAI, per the course model) and shares the DevOps workflow. Ownership means being the main author and reviewer for that area — integration, deployment, and debugging were done collaboratively across subsystem boundaries. Contributions are traceable through commit and PR authorship, code-review participation on merged PRs, and the per-person weekly reports in docs/weekly-reports/.
| Student | GitHub | Primary subsystem | Key artifacts |
|---|---|---|---|
| Pavel Tkachuk | @dedvnutree | Web client | calorie-app/ — React SPA (FSD architecture, Zustand stores, diary / insights / profile / scan flows); Playwright e2e suites incl. the real-stack smoke test (calorie-app/e2e/); DB seed tooling (scripts/seed.mjs); shared JWT verification module (services/common-security/); security & code-review hardening across the Spring services |
| Melisa Şahinoğlu | @sahinoglumelisa | GenAI service | services/genai-service/ — vision pipeline (Gemini primary + OpenRouter Nemotron fallback), RAG health insights (Weaviate + fastembed), pytest suites and the RAG retrieval eval; substantial parts of the Spring services (photo-scan flow, auth/analytics endpoints); docs/System Architecture.md |
| Deniz Öztürk | @StateofDisarray | Server & operations | Spring Boot backend (initial skeleton → three services); Helm chart (helm/calorieasy/); CI/CD workflows (.github/workflows/); observability stack (Prometheus / Grafana / Loki — dashboards & alert rules); IaC (infra/terraform/, infra/ansible/); HPA & canary bonus features; iOS (ios-app/) |
A single-page web client and an iOS client talk to three Spring Boot REST microservices behind one PostgreSQL instance (one database, three schemas). The meals service can delegate image recognition to the Python GenAI service. Only auth-service issues JWTs — it signs them RS256 with the RSA private key (APP_JWT_PRIVATE_KEY); meals and analytics verify with the public key only (APP_JWT_PUBLIC_KEY), so a compromised resource service cannot mint tokens. analytics-service is a read-side aggregator — it does not store meals; it fetches them live from meals-service over HTTP, forwarding the caller's bearer token.
┌──────────────────────────────────────────────┐
Web client (SPA) │ Reverse proxy (/api/*) │
Vite dev :3000 │ · dev: Vite proxy (per-service) │
nginx prod :80 ───► │ · prod: nginx (in the web image) │
iOS app (local-only) └───────────────┬──────────────────────────────┘
│ Authorization: Bearer <JWT>
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────────┐
│ auth-service │ │ meals-service │ │ analytics-service │
│ :8081 │ │ :8082 │ │ :8083 │
│ /api/auth │ │ /api/meals │ │ /api/analytics │
│ /api/users │ │ (issues no JWT; │ │ /api/goals │
│ ISSUES JWT ─────┼──┐ │ validates JWT) │◄─────┤ (validates JWT; │
│ │ │ │ │ GET │ no meal storage) │
└─────────┬─────────┘ │ └─────────┬─────────┘/api/ └───────────┬───────────┘
│ │ │ meals │
│ RS256 │ │ POST /api/analyze │
│ public │ ▼ (when app.genai.base-url │
│ key │ ┌───────────────────┐ is set) │
│ (verify) └──►│ genai-service │ │
│ │ :8084 │ │
│ │ FastAPI + vision │ │
│ │ LLM (Gemini + │ │
│ │ Nemotron backup)│ │
│ └───────────────────┘ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL 16 (DB: nutrition) schemas: auth | meals | analytics │
│ :5432 Flyway migrations per service │
└──────────────────────────────────────────────────────────────────────────┘
Design docs live in docs/: Problem Statement.md, System Architecture.md (with the Subsystem Decomposition), and the UML diagrams usecase-diagram.md and object-diagram.md. The original design-phase sketches are kept as docs/OLD_*.png to show how the architecture evolved.
| Layer | Technology |
|---|---|
| Web client | React 19.2, TypeScript 5.9, Vite 8, Zustand 5, CSS Modules, Vitest |
| Backend services | Java 21, Spring Boot 3.5.13 (Web MVC, Data JPA, Security, Actuator, Validation), Maven |
| Auth | JJWT 0.13.0 (RS256), BCrypt — auth signs with APP_JWT_PRIVATE_KEY, other services verify with APP_JWT_PUBLIC_KEY |
| Persistence | PostgreSQL 16, Flyway migrations, Hibernate ddl-auto=validate |
| GenAI service | Python 3.11, FastAPI 0.104, uvicorn, LangChain, Pydantic 2 |
| Vision LLMs | Google Gemini (primary, OpenAI-compatible), Nemotron via OpenRouter (backup) |
| Nutrition data | USDA FoodData Central with a local nutrition_db.json cache fallback |
| iOS | Swift 5, SwiftUI, SwiftData, WidgetKit, Swift Charts (xcodegen project) |
| API docs | springdoc-openapi (Swagger UI) per Spring service; FastAPI /docs for GenAI |
| Infra / CI | Docker Compose, Helm v3, Kubernetes (AET cluster), Terraform + Ansible (Azure VM), GitHub Actions, GHCR |
make startBuilds and starts the entire stack (Postgres, the three Spring services, GenAI +
Weaviate, web client, monitoring), waits until the services answer, and seeds
demo data. On the first run it also creates .env with a fresh RS256 JWT
keypair. Then open http://localhost:3000 and log in with
dev@local.com / password123.
Requires Docker and Node.js 18+. Without a Gemini key, photo scan falls back to
the manual nutrition form; add OPENAI_API_KEY to .env for real AI analysis
(see Configuration).
The fastest way to try the full web app. This skips the GenAI service entirely — every feature works except photo recognition, which falls back to manual entry. Demo data is loaded into the real database via the seed script, so the app runs against the real backend.
Requires Docker and Node.js 18+.
# From the repo root:
cp .env.example .env
node scripts/gen-jwt-keys.mjs >> .env # generate the RS256 JWT keypair
# 1. Start everything except the GenAI service (postgres + the 3 services + web):
docker compose up --build --no-deps -d postgres auth-service meals-service analytics-service web
# 2. Once the services are up, seed one demo user + ~10 days of meals:
node scripts/seed.mjs # or: make seed--no-deps is what actually keeps the AI stack out: meals-service and
analytics-service declare depends_on: genai-service, which in turn depends on
weaviate, so without the flag Compose starts both of them transitively even
though they aren't listed.
Then open http://localhost:3000 and log in with:
- Email:
dev@local.com - Password:
password123
The profile, daily goals, diary history, insights, and logging streak are all populated from the seeded data. The "Scan meal" button works too — without the AI service it just routes to the manual nutrition form.
Seed options: re-running clears the seed user's existing meals first, so you
always get a clean dataset (pass --keep to append instead). Override with
SEED_DAYS, SEED_EMAIL, SEED_PASSWORD. See scripts/seed.mjs.
Brings up Postgres + the three Spring services + the GenAI service + the web client (Vite dev server) on one machine.
- Docker + Docker Compose v2
- A Google AI Studio key for Gemini (primary) and optionally an OpenRouter key for the Nemotron backup — see Configuration
The dev Compose stack wires
meals-servicetogenai-serviceby default (APP_GENAI_BASE_URLdefaults tohttp://genai-service:8084), so photo analysis uses the real GenAI link as long as an API key is configured; without keys the UI falls back to the manual nutrition form.
make start # then add OPENAI_API_KEY (+ optional BACKUP_OPENAI_API_KEY) to .env and re-runor manually:
cp .env.example .env # fill OPENAI_API_KEY + BACKUP_OPENAI_API_KEY
node scripts/gen-jwt-keys.mjs >> .env # generate the RS256 JWT keypair
docker compose up --buildApp opens at http://localhost:3000. All five services + Postgres come up in one command.
To pre-populate demo data (one user + ~10 days of meals), run the seed script once the services are up:
node scripts/seed.mjs # or: make seedAfter seeding, hard refresh the browser tab if the app is already open, then log in with dev@local.com / password123.
Provider is selected with LLM_PROVIDER (openai | google); see Configuration.
SwiftUI + SwiftData client with backend sync (auth, meals, goals, GenAI analyze). Water tracking and widget stay local. Requires macOS + Xcode 15+ (iOS 17 SDK) and xcodegen.
cd ios-app
xcodegen generate # regenerate NutritionApp.xcodeproj from project.yml
open NutritionApp.xcodeproj
# select the NutritionApp scheme + an iOS 17 simulator, then Cmd-ROptional scheme launch arguments: --seed-sample-data (7 days of meals/water), --skip-onboarding. Headless build:
xcodebuild -project NutritionApp.xcodeproj -scheme NutritionApp \
-destination 'platform=iOS Simulator,name=iPhone 15' buildAll Spring endpoints are served under the /api prefix; all except registration/login/health/Swagger require Authorization: Bearer <JWT>.
| Method | Path | Notes |
|---|---|---|
POST |
/api/auth/register |
Create user, returns AuthResponse (JWT); public |
POST |
/api/auth/login |
Authenticate, returns AuthResponse (JWT); public |
GET |
/api/users/me |
Current user's profile (from JWT) |
PUT |
/api/users/me |
Full-replace profile (name + body metrics + activity/goal) |
GET |
/actuator/health, /swagger-ui.html |
Public |
JWT subject is the user's email; the user id is a custom userId claim. The auth filter re-loads the user from the DB on every request. Token TTL is an ISO-8601 duration (APP_JWT_EXPIRATION, default PT24H).
| Method | Path | Notes |
|---|---|---|
POST |
/api/meals/manual |
Create a manual meal; sums item macros; source=MANUAL |
GET |
/api/meals?from=YYYY-MM-DD&to=YYYY-MM-DD |
List caller's meals in an inclusive date range |
GET |
/api/meals/{id} |
Get one owned meal |
PUT |
/api/meals/{id} |
Update an owned meal |
DELETE |
/api/meals/{id} |
Delete an owned meal (204) |
POST |
/api/meals/photo |
Multipart file; stores image, status AI_NOT_AVAILABLE (no analysis) |
POST |
/api/meals/photo/{id}/convert-manual |
Attach manual macros to a photo log |
POST |
/api/meals/analyze |
Multipart image; runs GenAI analyzer; source=PHOTO_AI |
POST |
/api/meals/estimate |
JSON { "foodName": "..." }; per-100g nutrition estimate via genai |
GET |
/api/meals/photo/{id}/raw |
Stream stored photo bytes (owner only) |
POST /api/meals/photoonly stores the file and never triggers AI. Image analysis happens only viaPOST /api/meals/analyze. Whenapp.genai.base-urlis set, that endpoint calls the GenAI service atPOST /api/analyze; otherwise it uses a deterministic, non-AI placeholder analyzer. Multipart upload limit is 10 MB.
| Method | Path | Notes |
|---|---|---|
GET |
/api/analytics/daily?date=YYYY-MM-DD |
Daily totals + per-macro goal deltas |
GET |
/api/analytics/weekly?weekStart=YYYY-MM-DD |
7-day totals + deltas vs. (daily goal × 7) |
GET |
/api/analytics/range?from=DATE&to=DATE |
Per-day totals for a range (≤400 days, empty days omitted) |
GET |
/api/analytics/streak |
Consecutive-days logging streak (~5-year window) |
GET |
/api/analytics/insight?window=week |
RAG health insight from recent meals (via genai) |
GET |
/api/goals |
Current user's goal, or 204 if none set |
PUT |
/api/goals |
Upsert nutrition goal (GoalRequest; includes fiberGrams) |
| Service | URL |
|---|---|
| Auth | http://localhost:8081/swagger-ui.html |
| Meals | http://localhost:8082/swagger-ui.html |
| Analytics | http://localhost:8083/swagger-ui.html |
| GenAI | http://localhost:8084/docs |
Live at https://team-password123-devops-ss26.stud.k8s.aet.cit.tum.de
Deployed automatically on every push to main via Helm. GenAI uses Google Gemini in production with OpenRouter Nemotron as automatic fallback when Gemini fails.
See DEPLOYMENT.md for the full deployment guide.
Goal deltas are actual − target; with no goal set, target is treated as 0. If meals-service is down, analytics returns 502.
| Method | Path | Notes |
|---|---|---|
GET |
/health |
Readiness (ok / degraded) |
POST |
/api/analyze |
Multipart file → NutritionResponse (foods, calories, protein/carbs/fat/fiber grams, confidence) |
POST |
/api/analyze/compare |
Internal (hidden from OpenAPI): run two providers and compare calorie estimates |
POST |
/api/estimate |
JSON { "foodName": "..." } → per-100g nutrition estimate |
POST |
/api/insight |
JSON eating profile → RAG health insight |
meals-service calls only POST /api/analyze (multipart field file) and ignores fiber_grams in its mapping.
Shared / Postgres (.env.example)
| Variable | Default | Purpose |
|---|---|---|
POSTGRES_DB |
nutrition |
Database name |
POSTGRES_USER |
nutrition |
DB user |
POSTGRES_PASSWORD |
nutrition (dev) |
DB password — required in prod |
APP_JWT_PRIVATE_KEY |
— (generate) | RS256 signing key (PKCS#8, base64) — auth-service only; node scripts/gen-jwt-keys.mjs >> .env |
APP_JWT_PUBLIC_KEY |
— (generate) | RS256 verification key (SPKI, base64) for meals/analytics — verify-only, cannot mint tokens |
APP_JWT_EXPIRATION |
PT24H |
JWT TTL (ISO-8601 duration) |
| Variable | Default | Purpose |
|---|---|---|
SPRING_DATASOURCE_URL |
jdbc:postgresql://localhost:5432/nutrition?currentSchema=<svc> |
Per-service JDBC URL |
SPRING_DATASOURCE_USERNAME / _PASSWORD |
nutrition / nutrition |
DB creds |
SERVER_PORT |
8081 / 8082 / 8083 |
HTTP port |
APP_UPLOAD_DIR |
uploads |
meals-service photo storage dir |
MEALS_SERVICE_URL |
http://localhost:8082 |
analytics → meals base URL |
APP_GENAI_BASE_URL |
unset | meals: activates the GenAI analyzer ({base}/api/analyze) when set |
GenAI service (services/genai-service/.env.example)
| Variable | Default | Purpose |
|---|---|---|
LLM_PROVIDER |
openai |
openai | google |
OPENAI_API_KEY / OPENAI_MODEL / OPENAI_BASE_URL |
empty / gemini-3.1-flash-lite / Gemini OpenAI-compatible URL |
Primary vision LLM (Gemini in prod) |
BACKUP_OPENAI_API_KEY / BACKUP_OPENAI_MODEL / BACKUP_OPENAI_BASE_URL |
empty | OpenRouter Nemotron fallback when primary fails |
GOOGLE_API_KEY / GOOGLE_MODEL |
empty / gemini-2.0-flash |
Native Google path (optional) |
NUTRITION_DATA_PROVIDER |
usda |
auto | usda | local |
USDA_FDC_API_KEY |
empty | Without it, USDA lookups are skipped and only the local cache is used |
PORT / DEBUG |
8084 / false |
Server port / debug logging |
GenAI provider by environment.
- Dev / Compose / prod →
openaiwith Google Gemini primary plus OpenRouter Nemotron backup. - Helm / AET k8s → same; deploy workflow may override base URL, model, and key with cluster secrets.
Gemini is reached via LLM_PROVIDER=openai plus a Gemini OpenAI-compatible OPENAI_BASE_URL, not the native google path.
calorie-app/.env.example holds the committed defaults; copy it to calorie-app/.env.local (gitignored) to override locally.
| Variable | Default | Purpose |
|---|---|---|
VITE_AUTH_API_URL / VITE_MEALS_API_URL / VITE_ANALYTICS_API_URL |
:8081 / :8082 / :8083 |
Per-service dev proxy targets, read by vite.config.ts |
| Component | Command | Coverage |
|---|---|---|
| Web client | cd calorie-app && npm test |
Vitest, 60 unit tests (profile goals, mappers, insights period/bars/dates, meal scaling, health insight card). No full-page component tests. |
| Backend services | cd services && mvn test |
JUnit 5 + Mockito, unit-only; the aggregator builds the shared common-security module first, then auth (JwtService, AuthService, UserService), meals (MealService, MealMapper, GenAiMealAnalyzer mapping) and analytics (AnalyticsService, GoalService) |
| genai-service (unit) | cd services/genai-service && pytest tests/test_nutrition_lookup.py -v |
Pure unit tests, no running server |
| genai-service (vision) | pytest tests/test_vision_fallback.py -v -m "not integration" |
Gemini primary + backup fallback (mocked, no keys) |
| genai-service (backup smoke) | pytest tests/test_vision_fallback.py -v -m "integration and backup" |
OpenRouter backup smoke only (skips without key) |
| genai-service (smoke) | pytest tests/test_smoke.py -v |
HTTP smoke tests against a running service (auto-skips if down) |
| iOS app | xcodebuild test (committed .xcodeproj) |
Live backend integration tests in NutritionAppTests/LiveIntegrationTests.swift. Note: the xcodegen project.yml does not define a test target, so xcodegen generate drops it — use the committed .xcodeproj. |
Backend tests are unit-only (mocked collaborators, no Spring context / DB). There are no @SpringBootTest/@WebMvcTest integration tests.
Four GitHub Actions workflows in .github/workflows/:
ci.yml— on every PR and push tomain: a 3-way backend matrix installs the shared common-security module and runsmvn -B -ntp verify(JDK 21 temurin) for auth/meals/analytics; the frontend job runsnpm ci, lint,npm run build(stricttsc+ Vite build) andnpm test(Node 20); a Playwright e2e job runs the browser flows; and a genai job runsruff+pyteston the headless unit suites (Python 3.11).build-images.yml— on push tomain(or manual dispatch): builds fivelinux/amd64images (auth-service, meals-service, analytics-service, genai-service, web) and pushes them to GHCR tagged:latestand:<sha>.deploy-aet.yml— auto-runs after a successful image build onmain; deploys the Helm chart to the AET Kubernetes cluster by commit SHA (immutable tag).deploy-azure.yml— manual-only (workflow_dispatch): Terraformfmt/validatethen an Ansible Docker Compose deploy to an Azure VM (paused to save credits).
The chart helm/calorieasy/ deploys (release app) to namespace team-password123. A K8s Ingress routes to web:80 — the web pod's nginx serves the SPA and reverse-proxies /api/* to the backends — with cert-manager TLS (cluster-issuer letsencrypt-prod, secret team-password123-tls). Ingress host: team-password123-devops-ss26.stud.k8s.aet.cit.tum.de. Images come from ghcr.io/aet-devops26/team-password123/*.
# pull secret for the private GHCR packages
kubectl create secret docker-registry ghcr-pull \
--docker-server=ghcr.io --docker-username=<user> --docker-password=<token> \
-n team-password123
helm upgrade --install app helm/calorieasy \
--namespace team-password123 \
--set image.tag=<sha> \
--set genai.openaiApiKey=<key>
# access via port-forward
kubectl port-forward -n team-password123 svc/web 8080:80# 1. provision the VM (one-time, local)
cd infra/terraform
terraform init
terraform apply -var ssh_public_key="$(cat ~/.ssh/calorieasy_id.pub)"
# 2. configure + deploy
cd ../ansible
cp inventory.example.ini inventory.ini # fill in the public IP
ansible-playbook -i inventory.ini playbook.yml \
-e ghcr_user=... -e ghcr_token=... -e postgres_password=... \
-e jwt_private_key=... -e jwt_public_key=...The prod Compose stack (docker-compose.prod.yml) pulls prebuilt GHCR images and serves the web client via nginx on :80 (Postgres is not published externally). It is a simplified fallback environment — no monitoring stack or Weaviate, so insights run in degraded mode; the full observable deployment is the AET cluster. See DEPLOYMENT.md for the full operator runbook, secrets, and variables.
Security note: secrets (
APP_JWT_PRIVATE_KEY,POSTGRES_PASSWORD, Helmjwt.privateKey/postgres.password) have no committed defaults and must be provided for any deployment. The prod Compose stack enforces this via required (:?) variables. The JWT public key (APP_JWT_PUBLIC_KEY/jwt.publicKey) is not sensitive — it only verifies tokens.
- Three Spring Boot microservices (auth, meals, analytics) with schema-per-service isolation
- Python FastAPI GenAI service (Gemini primary, OpenRouter Nemotron backup)
- React web client — all pages (diary, scan, insights, profile, onboarding)
- iOS SwiftUI client with backend sync (auth, meals, goals, GenAI analyze)
- Docker Compose (dev + prod) — one-command local setup
- GitHub Actions CI — build + test all services, Vitest, Playwright e2e, genai pytest
- GHCR image build & push (5 images, immutable SHA tags)
- Helm chart + Kubernetes deployment (AET cluster, ingress-nginx, TLS via cert-manager)
- Terraform (Azure VM) + Ansible IaC
- Prometheus + Grafana observability (metrics, dashboard, alert rules)
- iOS unit/UI test target
- Resilience4j circuit-breaker on analytics → meals call (Sprint 5 stretch)
Prometheus + Grafana ship in both the docker-compose stack and the Helm chart.
Metrics. Each Spring service exposes Micrometer metrics at /actuator/prometheus; the GenAI service exposes /metrics (FastAPI instrumentator + custom calorieasy_genai_analyze_* counters/histogram). Prometheus scrapes all four every 15s (infra/monitoring/prometheus.yml for compose; a ConfigMap in helm/.../monitoring-prometheus.yaml for k8s — same targets).
Dashboard. A provisioned Grafana dashboard (helm/calorieasy/dashboards/calorieasy-overview.json) covers request rate, error rate, p50/p95/p99 latency, JVM/GC/threads/CPU, HikariCP connections, error-level log events, and a GenAI row (analyses by result + analysis latency).
Alerts. helm/calorieasy/alerts.yml (one source of truth for both compose and the chart) defines: ServiceDown (up==0 2m), HighRequestLatency (p95 > 1s, 5m), HighServerErrorRate (>5% 5xx, 5m). Firing alerts show on Prometheus /alerts.
Access.
# Local (docker compose): Grafana on :3001, Prometheus on :9090
# login: admin / $GRAFANA_ADMIN_PASSWORD (from .env)
open http://localhost:3001
# AET cluster: Grafana is served at https://<ingress-host>/grafana| Document | Description |
|---|---|
| API Reference | Full endpoint docs — request/response bodies for all services |
| System Architecture | Architecture diagram and service descriptions |
| Sprint Plan | Sprint history and upcoming work |
| Problem Statement | Product vision and user scenarios |
| Deployment Guide | K8s + Azure VM deployment instructions |
| services/README.md | Cross-cutting backend patterns (JWT, DB, inter-service calls) |