diff --git a/.gitignore b/.gitignore
index 41e4430..6339b74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,4 +60,5 @@ tmp/
*.tmp
#.claude Konfiguration
-.claude/
\ No newline at end of file
+.claude/
+.claude/settings.local.json
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 5442bd0..06fbe4b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,7 +26,25 @@
```
mitai-jinkendo/
├── backend/
-│ ├── main.py # FastAPI App, alle Endpoints (~2000 Zeilen)
+│ ├── main.py # FastAPI App Setup + Router Registration (~75 Zeilen)
+│ ├── db.py # PostgreSQL Connection Pool + Helpers
+│ ├── auth.py # Auth Functions (hash, verify, sessions)
+│ ├── models.py # Pydantic Models (11 Models)
+│ ├── routers/ # Modular Endpoint Structure (14 Router)
+│ │ ├── auth.py # Login, Logout, Password Reset (7 Endpoints)
+│ │ ├── profiles.py # Profile CRUD + Current User (7 Endpoints)
+│ │ ├── weight.py # Weight Tracking (5 Endpoints)
+│ │ ├── circumference.py # Body Measurements (4 Endpoints)
+│ │ ├── caliper.py # Skinfold Tracking (4 Endpoints)
+│ │ ├── activity.py # Workout Logging + CSV Import (6 Endpoints)
+│ │ ├── nutrition.py # Nutrition + FDDB Import (4 Endpoints)
+│ │ ├── photos.py # Progress Photos (3 Endpoints)
+│ │ ├── insights.py # AI Analysis + Pipeline (8 Endpoints)
+│ │ ├── prompts.py # AI Prompt Management (2 Endpoints)
+│ │ ├── admin.py # User Management (7 Endpoints)
+│ │ ├── stats.py # Dashboard Stats (1 Endpoint)
+│ │ ├── exportdata.py # CSV/JSON/ZIP Export (3 Endpoints)
+│ │ └── importdata.py # ZIP Import (1 Endpoint)
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
@@ -76,6 +94,7 @@ mitai-jinkendo/
- ✅ PostgreSQL 16 Migration (vollständig von SQLite migriert)
- ✅ Export: CSV, JSON, ZIP (mit Fotos)
- ✅ Automatische SQLite→PostgreSQL Migration bei Container-Start
+- ✅ **Modulare Backend-Architektur**: 14 Router-Module, main.py von 1878→75 Zeilen (-96%)
### Was in v9c kommt:
- 🔲 Selbst-Registrierung mit E-Mail-Bestätigung
diff --git a/backend/main.py b/backend/main.py
index 11bb183..7d7249a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,41 +1,38 @@
-import os, csv, io, uuid, json, zipfile
-from pathlib import Path
-from typing import Optional
-from datetime import datetime
-from decimal import Decimal
+"""
+Mitai Jinkendo API - Main Application
-from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Depends
+FastAPI backend with modular router structure.
+"""
+import os
+from pathlib import Path
+
+from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import StreamingResponse, FileResponse, Response
-from pydantic import BaseModel
-import aiofiles
-import bcrypt
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
-from starlette.requests import Request
-from db import get_db, get_cursor, r2d, init_db
-from auth import hash_pin, verify_pin, make_token, get_session, require_auth, require_auth_flexible, require_admin
-from models import (
- ProfileCreate, ProfileUpdate, WeightEntry, CircumferenceEntry,
- CaliperEntry, ActivityEntry, NutritionDay, LoginRequest,
- PasswordResetRequest, PasswordResetConfirm, AdminProfileUpdate
-)
+from db import init_db
+# Import routers
+from routers import auth, profiles, weight, circumference, caliper
+from routers import activity, nutrition, photos, insights, prompts
+from routers import admin, stats, exportdata, importdata
+
+# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
-OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
-OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
-ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
-
app = FastAPI(title="Mitai Jinkendo API", version="3.0.0")
+
+# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+
+# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
@@ -44,1835 +41,34 @@ app.add_middleware(
allow_headers=["*"],
)
-AVATAR_COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
-
+# ── Startup Event ─────────────────────────────────────────────────────────────
@app.on_event("startup")
async def startup_event():
- """Run migrations and initialization on startup."""
+ """Run database initialization on startup."""
try:
init_db()
except Exception as e:
print(f"⚠️ init_db() failed (non-fatal): {e}")
- # Don't crash on startup - pipeline prompt can be created manually
-
-# ── Helper: get profile_id from header ───────────────────────────────────────
-def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
- """Get profile_id - from header for legacy endpoints."""
- if x_profile_id:
- return x_profile_id
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
- row = cur.fetchone()
- if row: return row['id']
- raise HTTPException(400, "Kein Profil gefunden")
-
-# ── Models ────────────────────────────────────────────────────────────────────
-# ── Profiles ──────────────────────────────────────────────────────────────────
-from datetime import timedelta
-
-# Models moved to models.py
-# Auth functions moved to auth.py
-
-@app.get("/api/profiles")
-def list_profiles(session=Depends(require_auth)):
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM profiles ORDER BY created")
- rows = cur.fetchall()
- return [r2d(r) for r in rows]
-
-@app.post("/api/profiles")
-def create_profile(p: ProfileCreate, session=Depends(require_auth)):
- pid = str(uuid.uuid4())
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated)
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""",
- (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct))
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
- return r2d(cur.fetchone())
-
-@app.get("/api/profiles/{pid}")
-def get_profile(pid: str, session=Depends(require_auth)):
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
- row = cur.fetchone()
- if not row: raise HTTPException(404, "Profil nicht gefunden")
- return r2d(row)
-
-@app.put("/api/profiles/{pid}")
-def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
- with get_db() as conn:
- data = {k:v for k,v in p.model_dump().items() if v is not None}
- data['updated'] = datetime.now().isoformat()
- cur = get_cursor(conn)
- cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s",
- list(data.values())+[pid])
- return get_profile(pid, session)
-
-@app.delete("/api/profiles/{pid}")
-def delete_profile(pid: str, session=Depends(require_auth)):
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT COUNT(*) as count FROM profiles")
- count = cur.fetchone()['count']
- if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
- for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']:
- cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
- cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
- return {"ok": True}
-
-@app.get("/api/profile")
-def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
- """Legacy endpoint – returns active profile."""
- pid = get_pid(x_profile_id)
- return get_profile(pid, session)
-
-@app.put("/api/profile")
-def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
- pid = get_pid(x_profile_id)
- return update_profile(pid, p, session)
-
-# ── Weight ────────────────────────────────────────────────────────────────────
-@app.get("/api/weight")
-def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- "SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
- return [r2d(r) for r in cur.fetchall()]
-
-@app.post("/api/weight")
-def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
- ex = cur.fetchone()
- if ex:
- cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id']))
- wid = ex['id']
- else:
- wid = str(uuid.uuid4())
- cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)",
- (wid,pid,e.date,e.weight,e.note))
- return {"id":wid,"date":e.date,"weight":e.weight}
-
-@app.put("/api/weight/{wid}")
-def update_weight(wid: str, e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("UPDATE weight_log SET date=%s,weight=%s,note=%s WHERE id=%s AND profile_id=%s",
- (e.date,e.weight,e.note,wid,pid))
- return {"id":wid}
-
-@app.delete("/api/weight/{wid}")
-def delete_weight(wid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("DELETE FROM weight_log WHERE id=%s AND profile_id=%s", (wid,pid))
- return {"ok":True}
-
-@app.get("/api/weight/stats")
-def weight_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
- rows = cur.fetchall()
- if not rows: return {"count":0,"latest":None,"prev":None,"min":None,"max":None,"avg_7d":None}
- w=[float(r['weight']) for r in rows]
- return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":float(rows[0]['weight'])},
- "prev":{"date":rows[1]['date'],"weight":float(rows[1]['weight'])} if len(rows)>1 else None,
- "min":min(w),"max":max(w),"avg_7d":round(sum(w[:7])/min(7,len(w)),2)}
-
-# ── Circumferences ────────────────────────────────────────────────────────────
-@app.get("/api/circumferences")
-def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- "SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
- return [r2d(r) for r in cur.fetchall()]
-
-@app.post("/api/circumferences")
-def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
- ex = cur.fetchone()
- d = e.model_dump()
- if ex:
- eid = ex['id']
- sets = ', '.join(f"{k}=%s" for k in d if k!='date')
- cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
- [v for k,v in d.items() if k!='date']+[eid])
- else:
- eid = str(uuid.uuid4())
- cur.execute("""INSERT INTO circumference_log
- (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
- (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
- d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
- return {"id":eid,"date":e.date}
-
-@app.put("/api/circumferences/{eid}")
-def update_circ(eid: str, e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- d = e.model_dump()
- cur = get_cursor(conn)
- cur.execute(f"UPDATE circumference_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
- list(d.values())+[eid,pid])
- return {"id":eid}
-
-@app.delete("/api/circumferences/{eid}")
-def delete_circ(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("DELETE FROM circumference_log WHERE id=%s AND profile_id=%s", (eid,pid))
- return {"ok":True}
-
-# ── Caliper ───────────────────────────────────────────────────────────────────
-@app.get("/api/caliper")
-def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- "SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
- return [r2d(r) for r in cur.fetchall()]
-
-@app.post("/api/caliper")
-def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
- ex = cur.fetchone()
- d = e.model_dump()
- if ex:
- eid = ex['id']
- sets = ', '.join(f"{k}=%s" for k in d if k!='date')
- cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
- [v for k,v in d.items() if k!='date']+[eid])
- else:
- eid = str(uuid.uuid4())
- cur.execute("""INSERT INTO caliper_log
- (id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
- sf_abdomen,sf_thigh,sf_calf_med,sf_lowerback,sf_biceps,body_fat_pct,lean_mass,fat_mass,notes,created)
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
- (eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'],
- d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'],
- d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes']))
- return {"id":eid,"date":e.date}
-
-@app.put("/api/caliper/{eid}")
-def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- d = e.model_dump()
- cur = get_cursor(conn)
- cur.execute(f"UPDATE caliper_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
- list(d.values())+[eid,pid])
- return {"id":eid}
-
-@app.delete("/api/caliper/{eid}")
-def delete_caliper(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("DELETE FROM caliper_log WHERE id=%s AND profile_id=%s", (eid,pid))
- return {"ok":True}
-
-# ── Activity ──────────────────────────────────────────────────────────────────
-@app.get("/api/activity")
-def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC, start_time DESC LIMIT %s", (pid,limit))
- return [r2d(r) for r in cur.fetchall()]
-
-@app.post("/api/activity")
-def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- eid = str(uuid.uuid4())
- d = e.model_dump()
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("""INSERT INTO activity_log
- (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
- hr_avg,hr_max,distance_km,rpe,source,notes,created)
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
- (eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
- d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
- d['rpe'],d['source'],d['notes']))
- return {"id":eid,"date":e.date}
-
-@app.put("/api/activity/{eid}")
-def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- d = e.model_dump()
- cur = get_cursor(conn)
- cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
- list(d.values())+[eid,pid])
- return {"id":eid}
-
-@app.delete("/api/activity/{eid}")
-def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("DELETE FROM activity_log WHERE id=%s AND profile_id=%s", (eid,pid))
- return {"ok":True}
-
-@app.get("/api/activity/stats")
-def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
- rows = [r2d(r) for r in cur.fetchall()]
- if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
- total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
- total_min=sum(float(r.get('duration_min') or 0) for r in rows)
- by_type={}
- for r in rows:
- t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
- by_type[t]['count']+=1
- by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
- by_type[t]['min']+=float(r.get('duration_min') or 0)
- return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
-
-@app.post("/api/activity/import-csv")
-async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- raw = await file.read()
- try: text = raw.decode('utf-8')
- except: text = raw.decode('latin-1')
- if text.startswith('\ufeff'): text = text[1:]
- if not text.strip(): raise HTTPException(400,"Leere Datei")
- reader = csv.DictReader(io.StringIO(text))
- inserted = skipped = 0
- with get_db() as conn:
- cur = get_cursor(conn)
- for row in reader:
- wtype = row.get('Workout Type','').strip()
- start = row.get('Start','').strip()
- if not wtype or not start: continue
- try: date = start[:10]
- except: continue
- dur = row.get('Duration','').strip()
- duration_min = None
- if dur:
- try:
- p = dur.split(':')
- duration_min = round(int(p[0])*60+int(p[1])+int(p[2])/60,1)
- except: pass
- def kj(v):
- try: return round(float(v)/4.184) if v else None
- except: return None
- def tf(v):
- try: return round(float(v),1) if v else None
- except: return None
- try:
- cur.execute("""INSERT INTO activity_log
- (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
- hr_avg,hr_max,distance_km,source,created)
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""",
- (str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
- kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
- tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
- tf(row.get('Max. Herzfrequenz (count/min)','')),
- tf(row.get('Distanz (km)',''))))
- inserted+=1
- except: skipped+=1
- return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
-
-# ── Photos ────────────────────────────────────────────────────────────────────
-@app.post("/api/photos")
-async def upload_photo(file: UploadFile=File(...), date: str="",
- x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- fid = str(uuid.uuid4())
- ext = Path(file.filename).suffix or '.jpg'
- path = PHOTOS_DIR / f"{fid}{ext}"
- async with aiofiles.open(path,'wb') as f: await f.write(await file.read())
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
- (fid,pid,date,str(path)))
- return {"id":fid,"date":date}
-
-@app.get("/api/photos/{fid}")
-def get_photo(fid: str, session: dict=Depends(require_auth_flexible)):
- """Get photo by ID. Auth via header or query param (for
tags)."""
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT path FROM photos WHERE id=%s", (fid,))
- row = cur.fetchone()
- if not row: raise HTTPException(404, "Photo not found")
- photo_path = Path(PHOTOS_DIR) / row['path']
- if not photo_path.exists():
- raise HTTPException(404, "Photo file not found")
- return FileResponse(photo_path)
-
-@app.get("/api/photos")
-def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- "SELECT * FROM photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,))
- return [r2d(r) for r in cur.fetchall()]
-
-# ── Nutrition ─────────────────────────────────────────────────────────────────
-def _pf(s):
- try: return float(str(s).replace(',','.').strip())
- except: return 0.0
-
-@app.post("/api/nutrition/import-csv")
-async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- raw = await file.read()
- try: text = raw.decode('utf-8')
- except: text = raw.decode('latin-1')
- if text.startswith('\ufeff'): text = text[1:]
- if not text.strip(): raise HTTPException(400,"Leere Datei")
- reader = csv.DictReader(io.StringIO(text), delimiter=';')
- days: dict = {}
- count = 0
- for row in reader:
- rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
- if not rd: continue
- try:
- p = rd.split(' ')[0].split('.')
- iso = f"{p[2]}-{p[1]}-{p[0]}"
- except: continue
- days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0})
- days[iso]['kcal'] += _pf(row.get('kj',0))/4.184
- days[iso]['fat_g'] += _pf(row.get('fett_g',0))
- days[iso]['carbs_g'] += _pf(row.get('kh_g',0))
- days[iso]['protein_g'] += _pf(row.get('protein_g',0))
- count+=1
- inserted=0
- with get_db() as conn:
- cur = get_cursor(conn)
- for iso,vals in days.items():
- kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
- carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
- cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
- if cur.fetchone():
- cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
- (kcal,prot,fat,carbs,pid,iso))
- else:
- cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
- (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
- inserted+=1
- return {"rows_parsed":count,"days_imported":inserted,
- "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
-
-@app.get("/api/nutrition")
-def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute(
- "SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
- return [r2d(r) for r in cur.fetchall()]
-
-@app.get("/api/nutrition/correlations")
-def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date",(pid,))
- nutr={r['date']:r2d(r) for r in cur.fetchall()}
- cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,))
- wlog={r['date']:r['weight'] for r in cur.fetchall()}
- cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,))
- cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date'])
- all_dates=sorted(set(list(nutr)+list(wlog)))
- mi,last_cal,cal_by_date=0,{},{}
- for d in all_dates:
- while mi= limit:
- raise HTTPException(429, f"Tägliches KI-Limit erreicht ({limit} Calls)")
- return (True, limit, used)
-
-def inc_ai_usage(pid: str):
- """Increment AI usage counter for today."""
- today = datetime.now().date().isoformat()
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT id, call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
- row = cur.fetchone()
- if row:
- cur.execute("UPDATE ai_usage SET call_count=%s WHERE id=%s", (row['call_count']+1, row['id']))
- else:
- cur.execute("INSERT INTO ai_usage (id, profile_id, date, call_count) VALUES (%s,%s,%s,1)",
- (str(uuid.uuid4()), pid, today))
-
-def _get_profile_data(pid: str):
- """Fetch all relevant data for AI analysis."""
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
- prof = r2d(cur.fetchone())
- cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
- weight = [r2d(r) for r in cur.fetchall()]
- cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
- circ = [r2d(r) for r in cur.fetchall()]
- cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
- caliper = [r2d(r) for r in cur.fetchall()]
- cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
- nutrition = [r2d(r) for r in cur.fetchall()]
- cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
- activity = [r2d(r) for r in cur.fetchall()]
- return {
- "profile": prof,
- "weight": weight,
- "circumference": circ,
- "caliper": caliper,
- "nutrition": nutrition,
- "activity": activity
- }
-
-def _render_template(template: str, data: dict) -> str:
- """Simple template variable replacement."""
- result = template
- for k, v in data.items():
- result = result.replace(f"{{{{{k}}}}}", str(v) if v is not None else "")
- return result
-
-def _prepare_template_vars(data: dict) -> dict:
- """Prepare template variables from profile data."""
- prof = data['profile']
- weight = data['weight']
- circ = data['circumference']
- caliper = data['caliper']
- nutrition = data['nutrition']
- activity = data['activity']
-
- vars = {
- "name": prof.get('name', 'Nutzer'),
- "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich",
- "height": prof.get('height', 178),
- "goal_weight": float(prof.get('goal_weight')) if prof.get('goal_weight') else "nicht gesetzt",
- "goal_bf_pct": float(prof.get('goal_bf_pct')) if prof.get('goal_bf_pct') else "nicht gesetzt",
- "weight_aktuell": float(weight[0]['weight']) if weight else "keine Daten",
- "kf_aktuell": float(caliper[0]['body_fat_pct']) if caliper and caliper[0].get('body_fat_pct') else "unbekannt",
- }
-
- # Calculate age from dob
- if prof.get('dob'):
- try:
- from datetime import date
- dob = datetime.strptime(prof['dob'], '%Y-%m-%d').date()
- today = date.today()
- age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
- vars['age'] = age
- except:
- vars['age'] = "unbekannt"
- else:
- vars['age'] = "unbekannt"
-
- # Weight trend summary
- if len(weight) >= 2:
- recent = weight[:30]
- delta = float(recent[0]['weight']) - float(recent[-1]['weight'])
- vars['weight_trend'] = f"{len(recent)} Einträge, Δ30d: {delta:+.1f}kg"
- else:
- vars['weight_trend'] = "zu wenig Daten"
-
- # Caliper summary
- if caliper:
- c = caliper[0]
- bf = float(c.get('body_fat_pct')) if c.get('body_fat_pct') else '?'
- vars['caliper_summary'] = f"KF: {bf}%, Methode: {c.get('sf_method','?')}"
- else:
- vars['caliper_summary'] = "keine Daten"
-
- # Circumference summary
- if circ:
- c = circ[0]
- parts = []
- for k in ['c_waist', 'c_belly', 'c_hip']:
- if c.get(k): parts.append(f"{k.split('_')[1]}: {float(c[k])}cm")
- vars['circ_summary'] = ", ".join(parts) if parts else "keine Daten"
- else:
- vars['circ_summary'] = "keine Daten"
-
- # Nutrition summary
- if nutrition:
- n = len(nutrition)
- avg_kcal = sum(float(d.get('kcal',0) or 0) for d in nutrition) / n
- avg_prot = sum(float(d.get('protein_g',0) or 0) for d in nutrition) / n
- vars['nutrition_summary'] = f"{n} Tage, Ø {avg_kcal:.0f}kcal, {avg_prot:.0f}g Protein"
- vars['nutrition_detail'] = vars['nutrition_summary']
- vars['nutrition_days'] = n
- vars['kcal_avg'] = round(avg_kcal)
- vars['protein_avg'] = round(avg_prot,1)
- vars['fat_avg'] = round(sum(float(d.get('fat_g',0) or 0) for d in nutrition) / n,1)
- vars['carb_avg'] = round(sum(float(d.get('carbs_g',0) or 0) for d in nutrition) / n,1)
- else:
- vars['nutrition_summary'] = "keine Daten"
- vars['nutrition_detail'] = "keine Daten"
- vars['nutrition_days'] = 0
- vars['kcal_avg'] = 0
- vars['protein_avg'] = 0
- vars['fat_avg'] = 0
- vars['carb_avg'] = 0
-
- # Protein targets
- w = weight[0]['weight'] if weight else prof.get('height',178) - 100
- w = float(w) # Convert Decimal to float for math operations
- vars['protein_ziel_low'] = round(w * 1.6)
- vars['protein_ziel_high'] = round(w * 2.2)
-
- # Activity summary
- if activity:
- n = len(activity)
- total_kcal = sum(float(a.get('kcal_active',0) or 0) for a in activity)
- vars['activity_summary'] = f"{n} Trainings, {total_kcal:.0f}kcal gesamt"
- vars['activity_detail'] = vars['activity_summary']
- vars['activity_kcal_summary'] = f"Ø {total_kcal/n:.0f}kcal/Training"
- else:
- vars['activity_summary'] = "keine Daten"
- vars['activity_detail'] = "keine Daten"
- vars['activity_kcal_summary'] = "keine Daten"
-
- return vars
-
-@app.post("/api/insights/run/{slug}")
-async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- """Run AI analysis with specified prompt template."""
- pid = get_pid(x_profile_id)
- check_ai_limit(pid)
-
- # Get prompt template
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=true", (slug,))
- prompt_row = cur.fetchone()
- if not prompt_row:
- raise HTTPException(404, f"Prompt '{slug}' nicht gefunden")
-
- prompt_tmpl = prompt_row['template']
- data = _get_profile_data(pid)
- vars = _prepare_template_vars(data)
- final_prompt = _render_template(prompt_tmpl, vars)
-
- # Call AI
- if ANTHROPIC_KEY:
- # Use Anthropic SDK
- import anthropic
- client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
- response = client.messages.create(
- model="claude-sonnet-4-20250514",
- max_tokens=2000,
- messages=[{"role": "user", "content": final_prompt}]
- )
- content = response.content[0].text
- elif OPENROUTER_KEY:
- async with httpx.AsyncClient() as client:
- resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
- headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
- json={
- "model": OPENROUTER_MODEL,
- "messages": [{"role": "user", "content": final_prompt}],
- "max_tokens": 2000
- },
- timeout=60.0
- )
- if resp.status_code != 200:
- raise HTTPException(500, f"KI-Fehler: {resp.text}")
- content = resp.json()['choices'][0]['message']['content']
- else:
- raise HTTPException(500, "Keine KI-API konfiguriert")
-
- # Save insight
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug))
- cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
- (str(uuid.uuid4()), pid, slug, content))
-
- inc_ai_usage(pid)
- return {"scope": slug, "content": content}
-
-@app.post("/api/insights/pipeline")
-async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- """Run 3-stage pipeline analysis."""
- pid = get_pid(x_profile_id)
- check_ai_limit(pid)
-
- data = _get_profile_data(pid)
- vars = _prepare_template_vars(data)
-
- # Stage 1: Parallel JSON analyses
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT slug, template FROM ai_prompts WHERE slug LIKE 'pipeline_%' AND slug NOT IN ('pipeline_synthesis','pipeline_goals') AND active=true")
- stage1_prompts = [r2d(r) for r in cur.fetchall()]
-
- stage1_results = {}
- for p in stage1_prompts:
- slug = p['slug']
- final_prompt = _render_template(p['template'], vars)
-
- if ANTHROPIC_KEY:
- import anthropic
- client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
- response = client.messages.create(
- model="claude-sonnet-4-20250514",
- max_tokens=1000,
- messages=[{"role": "user", "content": final_prompt}]
- )
- content = response.content[0].text.strip()
- elif OPENROUTER_KEY:
- async with httpx.AsyncClient() as client:
- resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
- headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
- json={
- "model": OPENROUTER_MODEL,
- "messages": [{"role": "user", "content": final_prompt}],
- "max_tokens": 1000
- },
- timeout=60.0
- )
- content = resp.json()['choices'][0]['message']['content'].strip()
- else:
- raise HTTPException(500, "Keine KI-API konfiguriert")
-
- # Try to parse JSON, fallback to raw text
- try:
- stage1_results[slug] = json.loads(content)
- except:
- stage1_results[slug] = content
-
- # Stage 2: Synthesis
- vars['stage1_body'] = json.dumps(stage1_results.get('pipeline_body', {}), ensure_ascii=False)
- vars['stage1_nutrition'] = json.dumps(stage1_results.get('pipeline_nutrition', {}), ensure_ascii=False)
- vars['stage1_activity'] = json.dumps(stage1_results.get('pipeline_activity', {}), ensure_ascii=False)
-
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=true")
- synth_row = cur.fetchone()
- if not synth_row:
- raise HTTPException(500, "Pipeline synthesis prompt not found")
-
- synth_prompt = _render_template(synth_row['template'], vars)
-
- if ANTHROPIC_KEY:
- import anthropic
- client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
- response = client.messages.create(
- model="claude-sonnet-4-20250514",
- max_tokens=2000,
- messages=[{"role": "user", "content": synth_prompt}]
- )
- synthesis = response.content[0].text
- elif OPENROUTER_KEY:
- async with httpx.AsyncClient() as client:
- resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
- headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
- json={
- "model": OPENROUTER_MODEL,
- "messages": [{"role": "user", "content": synth_prompt}],
- "max_tokens": 2000
- },
- timeout=60.0
- )
- synthesis = resp.json()['choices'][0]['message']['content']
- else:
- raise HTTPException(500, "Keine KI-API konfiguriert")
-
- # Stage 3: Goals (only if goals are set)
- goals_text = None
- prof = data['profile']
- if prof.get('goal_weight') or prof.get('goal_bf_pct'):
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_goals' AND active=true")
- goals_row = cur.fetchone()
- if goals_row:
- goals_prompt = _render_template(goals_row['template'], vars)
-
- if ANTHROPIC_KEY:
- import anthropic
- client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
- response = client.messages.create(
- model="claude-sonnet-4-20250514",
- max_tokens=800,
- messages=[{"role": "user", "content": goals_prompt}]
- )
- goals_text = response.content[0].text
- elif OPENROUTER_KEY:
- async with httpx.AsyncClient() as client:
- resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
- headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
- json={
- "model": OPENROUTER_MODEL,
- "messages": [{"role": "user", "content": goals_prompt}],
- "max_tokens": 800
- },
- timeout=60.0
- )
- goals_text = resp.json()['choices'][0]['message']['content']
-
- # Combine synthesis + goals
- final_content = synthesis
- if goals_text:
- final_content += "\n\n" + goals_text
-
- # Save as 'gesamt' scope
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,))
- cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
- (str(uuid.uuid4()), pid, final_content))
-
- inc_ai_usage(pid)
- return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}
-
-@app.get("/api/prompts")
-def list_prompts(session: dict=Depends(require_auth)):
- """
- List AI prompts.
- - Admins: see ALL prompts (including pipeline and inactive)
- - Users: see only active single-analysis prompts
- """
- with get_db() as conn:
- cur = get_cursor(conn)
- is_admin = session.get('role') == 'admin'
-
- if is_admin:
- # Admin sees everything
- cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug")
- else:
- # Users see only active, non-pipeline prompts
- cur.execute("SELECT * FROM ai_prompts WHERE active=true AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order")
-
- return [r2d(r) for r in cur.fetchall()]
-
-@app.put("/api/prompts/{prompt_id}")
-def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)):
- """Update AI prompt template (admin only)."""
- with get_db() as conn:
- cur = get_cursor(conn)
- updates = []
- values = []
- if 'name' in data:
- updates.append('name=%s')
- values.append(data['name'])
- if 'description' in data:
- updates.append('description=%s')
- values.append(data['description'])
- if 'template' in data:
- updates.append('template=%s')
- values.append(data['template'])
- if 'active' in data:
- updates.append('active=%s')
- # Convert to boolean (accepts true/false, 1/0)
- values.append(bool(data['active']))
-
- if updates:
- cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s",
- values + [prompt_id])
-
- return {"ok": True}
-
-@app.get("/api/ai/usage")
-def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- """Get AI usage stats for current profile."""
- pid = get_pid(x_profile_id)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT ai_limit_day FROM profiles WHERE id=%s", (pid,))
- prof = cur.fetchone()
- limit = prof['ai_limit_day'] if prof else None
-
- today = datetime.now().date().isoformat()
- cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
- usage = cur.fetchone()
- used = usage['call_count'] if usage else 0
-
- cur.execute("SELECT date, call_count FROM ai_usage WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
- history = [r2d(r) for r in cur.fetchall()]
-
- return {
- "limit": limit,
- "used_today": used,
- "remaining": (limit - used) if limit else None,
- "history": history
- }
-
-# ── Auth ──────────────────────────────────────────────────────────────────────
-@app.post("/api/auth/login")
-@limiter.limit("5/minute")
-async def login(req: LoginRequest, request: Request):
- """Login with email + password."""
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),))
- prof = cur.fetchone()
- if not prof:
- raise HTTPException(401, "Ungültige Zugangsdaten")
-
- # Verify password
- if not verify_pin(req.password, prof['pin_hash']):
- raise HTTPException(401, "Ungültige Zugangsdaten")
-
- # Auto-upgrade from SHA256 to bcrypt
- if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'):
- new_hash = hash_pin(req.password)
- cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id']))
-
- # Create session
- token = make_token()
- session_days = prof.get('session_days', 30)
- expires = datetime.now() + timedelta(days=session_days)
- cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
- (token, prof['id'], expires.isoformat()))
-
- return {
- "token": token,
- "profile_id": prof['id'],
- "name": prof['name'],
- "role": prof['role'],
- "expires_at": expires.isoformat()
- }
-
-@app.post("/api/auth/logout")
-def logout(x_auth_token: Optional[str]=Header(default=None)):
- """Logout (delete session)."""
- if x_auth_token:
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,))
- return {"ok": True}
-
-@app.get("/api/auth/me")
-def get_me(session: dict=Depends(require_auth)):
- """Get current user info."""
- pid = session['profile_id']
- return get_profile(pid, session)
-
-@app.get("/api/auth/status")
-def auth_status():
- """Health check endpoint."""
+ # Don't crash on startup - can be created manually
+
+# ── Register Routers ──────────────────────────────────────────────────────────
+app.include_router(auth.router) # /api/auth/*
+app.include_router(profiles.router) # /api/profiles/*, /api/profile
+app.include_router(weight.router) # /api/weight/*
+app.include_router(circumference.router) # /api/circumferences/*
+app.include_router(caliper.router) # /api/caliper/*
+app.include_router(activity.router) # /api/activity/*
+app.include_router(nutrition.router) # /api/nutrition/*
+app.include_router(photos.router) # /api/photos/*
+app.include_router(insights.router) # /api/insights/*, /api/ai/*
+app.include_router(prompts.router) # /api/prompts/*
+app.include_router(admin.router) # /api/admin/*
+app.include_router(stats.router) # /api/stats
+app.include_router(exportdata.router) # /api/export/*
+app.include_router(importdata.router) # /api/import/*
+
+# ── Health Check ──────────────────────────────────────────────────────────────
+@app.get("/")
+def root():
+ """API health check."""
return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"}
-
-@app.put("/api/auth/pin")
-def change_pin(req: dict, session: dict=Depends(require_auth)):
- """Change PIN/password for current user."""
- pid = session['profile_id']
- new_pin = req.get('pin', '')
- if len(new_pin) < 4:
- raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
-
- new_hash = hash_pin(new_pin)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
-
- return {"ok": True}
-
-@app.post("/api/auth/forgot-password")
-@limiter.limit("3/minute")
-async def password_reset_request(req: PasswordResetRequest, request: Request):
- """Request password reset email."""
- email = req.email.lower().strip()
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
- prof = cur.fetchone()
- if not prof:
- # Don't reveal if email exists
- return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
-
- # Generate reset token
- token = secrets.token_urlsafe(32)
- expires = datetime.now() + timedelta(hours=1)
-
- # Store in sessions table (reuse mechanism)
- cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
- (f"reset_{token}", prof['id'], expires.isoformat()))
-
- # Send email
- try:
- import smtplib
- from email.mime.text import MIMEText
- smtp_host = os.getenv("SMTP_HOST")
- smtp_port = int(os.getenv("SMTP_PORT", 587))
- smtp_user = os.getenv("SMTP_USER")
- smtp_pass = os.getenv("SMTP_PASS")
- smtp_from = os.getenv("SMTP_FROM")
- app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
-
- if smtp_host and smtp_user and smtp_pass:
- msg = MIMEText(f"""Hallo {prof['name']},
-
-Du hast einen Passwort-Reset angefordert.
-
-Reset-Link: {app_url}/reset-password?token={token}
-
-Der Link ist 1 Stunde gültig.
-
-Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
-
-Dein Mitai Jinkendo Team
-""")
- msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo"
- msg['From'] = smtp_from
- msg['To'] = email
-
- with smtplib.SMTP(smtp_host, smtp_port) as server:
- server.starttls()
- server.login(smtp_user, smtp_pass)
- server.send_message(msg)
- except Exception as e:
- print(f"Email error: {e}")
-
- return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
-
-@app.post("/api/auth/reset-password")
-def password_reset_confirm(req: PasswordResetConfirm):
- """Confirm password reset with token."""
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP",
- (f"reset_{req.token}",))
- sess = cur.fetchone()
- if not sess:
- raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
-
- pid = sess['profile_id']
- new_hash = hash_pin(req.new_password)
- cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
- cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
-
- return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
-
-# ── Admin ─────────────────────────────────────────────────────────────────────
-@app.get("/api/admin/profiles")
-def admin_list_profiles(session: dict=Depends(require_admin)):
- """Admin: List all profiles with stats."""
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM profiles ORDER BY created")
- profs = [r2d(r) for r in cur.fetchall()]
-
- for p in profs:
- pid = p['id']
- cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s", (pid,))
- p['weight_count'] = cur.fetchone()['count']
- cur.execute("SELECT COUNT(*) as count FROM ai_insights WHERE profile_id=%s", (pid,))
- p['ai_insights_count'] = cur.fetchone()['count']
-
- today = datetime.now().date().isoformat()
- cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
- usage = cur.fetchone()
- p['ai_usage_today'] = usage['call_count'] if usage else 0
-
- return profs
-
-@app.put("/api/admin/profiles/{pid}")
-def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depends(require_admin)):
- """Admin: Update profile settings."""
- with get_db() as conn:
- updates = {k:v for k,v in data.model_dump().items() if v is not None}
- if not updates:
- return {"ok": True}
-
- cur = get_cursor(conn)
- cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in updates)} WHERE id=%s",
- list(updates.values()) + [pid])
-
- return {"ok": True}
-
-@app.put("/api/admin/profiles/{pid}/permissions")
-def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)):
- """Admin: Set profile permissions."""
- with get_db() as conn:
- cur = get_cursor(conn)
- updates = []
- values = []
- if 'ai_enabled' in data:
- updates.append('ai_enabled=%s')
- values.append(data['ai_enabled'])
- if 'ai_limit_day' in data:
- updates.append('ai_limit_day=%s')
- values.append(data['ai_limit_day'])
- if 'export_enabled' in data:
- updates.append('export_enabled=%s')
- values.append(data['export_enabled'])
- if 'role' in data:
- updates.append('role=%s')
- values.append(data['role'])
-
- if updates:
- cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid])
-
- return {"ok": True}
-
-@app.put("/api/admin/profiles/{pid}/email")
-def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)):
- """Admin: Set profile email."""
- email = data.get('email', '').strip().lower()
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid))
-
- return {"ok": True}
-
-@app.put("/api/admin/profiles/{pid}/pin")
-def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)):
- """Admin: Set profile PIN/password."""
- new_pin = data.get('pin', '')
- if len(new_pin) < 4:
- raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
-
- new_hash = hash_pin(new_pin)
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
-
- return {"ok": True}
-
-@app.get("/api/admin/email/status")
-def admin_email_status(session: dict=Depends(require_admin)):
- """Admin: Check email configuration status."""
- smtp_host = os.getenv("SMTP_HOST")
- smtp_user = os.getenv("SMTP_USER")
- smtp_pass = os.getenv("SMTP_PASS")
- app_url = os.getenv("APP_URL", "http://localhost:3002")
-
- configured = bool(smtp_host and smtp_user and smtp_pass)
-
- return {
- "configured": configured,
- "smtp_host": smtp_host or "",
- "smtp_user": smtp_user or "",
- "app_url": app_url
- }
-
-@app.post("/api/admin/email/test")
-def admin_test_email(data: dict, session: dict=Depends(require_admin)):
- """Admin: Send test email."""
- email = data.get('to', '')
- if not email:
- raise HTTPException(400, "E-Mail-Adresse fehlt")
-
- try:
- import smtplib
- from email.mime.text import MIMEText
- smtp_host = os.getenv("SMTP_HOST")
- smtp_port = int(os.getenv("SMTP_PORT", 587))
- smtp_user = os.getenv("SMTP_USER")
- smtp_pass = os.getenv("SMTP_PASS")
- smtp_from = os.getenv("SMTP_FROM")
-
- if not smtp_host or not smtp_user or not smtp_pass:
- raise HTTPException(500, "SMTP nicht konfiguriert")
-
- msg = MIMEText("Dies ist eine Test-E-Mail von Mitai Jinkendo.")
- msg['Subject'] = "Test-E-Mail"
- msg['From'] = smtp_from
- msg['To'] = email
-
- with smtplib.SMTP(smtp_host, smtp_port) as server:
- server.starttls()
- server.login(smtp_user, smtp_pass)
- server.send_message(msg)
-
- return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"}
- except Exception as e:
- raise HTTPException(500, f"Fehler beim Senden: {str(e)}")
-
-# ── Export ────────────────────────────────────────────────────────────────────
-@app.get("/api/export/csv")
-def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- """Export all data as CSV."""
- pid = get_pid(x_profile_id)
-
- # Check export permission
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
- prof = cur.fetchone()
- if not prof or not prof['export_enabled']:
- raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
-
- # Build CSV
- output = io.StringIO()
- writer = csv.writer(output)
-
- # Header
- writer.writerow(["Typ", "Datum", "Wert", "Details"])
-
- # Weight
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT date, weight, note FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
- for r in cur.fetchall():
- writer.writerow(["Gewicht", r['date'], f"{float(r['weight'])}kg", r['note'] or ""])
-
- # Circumferences
- cur.execute("SELECT date, c_waist, c_belly, c_hip FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
- for r in cur.fetchall():
- details = f"Taille:{float(r['c_waist'])}cm Bauch:{float(r['c_belly'])}cm Hüfte:{float(r['c_hip'])}cm"
- writer.writerow(["Umfänge", r['date'], "", details])
-
- # Caliper
- cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
- for r in cur.fetchall():
- writer.writerow(["Caliper", r['date'], f"{float(r['body_fat_pct'])}%", f"Magermasse:{float(r['lean_mass'])}kg"])
-
- # Nutrition
- cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
- for r in cur.fetchall():
- writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
-
- # Activity
- cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
- for r in cur.fetchall():
- writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
-
- output.seek(0)
- return StreamingResponse(
- iter([output.getvalue()]),
- media_type="text/csv",
- headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"}
- )
-
-@app.get("/api/export/json")
-def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- """Export all data as JSON."""
- pid = get_pid(x_profile_id)
-
- # Check export permission
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
- prof = cur.fetchone()
- if not prof or not prof['export_enabled']:
- raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
-
- # Collect all data
- data = {}
- with get_db() as conn:
- cur = get_cursor(conn)
-
- cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
- data['profile'] = r2d(cur.fetchone())
-
- cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
- data['weight'] = [r2d(r) for r in cur.fetchall()]
-
- cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
- data['circumferences'] = [r2d(r) for r in cur.fetchall()]
-
- cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
- data['caliper'] = [r2d(r) for r in cur.fetchall()]
-
- cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
- data['nutrition'] = [r2d(r) for r in cur.fetchall()]
-
- cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
- data['activity'] = [r2d(r) for r in cur.fetchall()]
-
- cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
- data['insights'] = [r2d(r) for r in cur.fetchall()]
-
- def decimal_handler(obj):
- if isinstance(obj, Decimal):
- return float(obj)
- return str(obj)
-
- json_str = json.dumps(data, indent=2, default=decimal_handler)
- return Response(
- content=json_str,
- media_type="application/json",
- headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.json"}
- )
-
-@app.get("/api/export/zip")
-def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
- """Export all data as ZIP (CSV + JSON + photos) per specification."""
- pid = get_pid(x_profile_id)
-
- # Check export permission & get profile
- with get_db() as conn:
- cur = get_cursor(conn)
- cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
- prof = r2d(cur.fetchone())
- if not prof or not prof.get('export_enabled'):
- raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
-
- # Helper: CSV writer with UTF-8 BOM + semicolon
- def write_csv(zf, filename, rows, columns):
- if not rows:
- return
- output = io.StringIO()
- writer = csv.writer(output, delimiter=';')
- writer.writerow(columns)
- for r in rows:
- writer.writerow([
- '' if r.get(col) is None else
- (float(r[col]) if isinstance(r.get(col), Decimal) else r[col])
- for col in columns
- ])
- # UTF-8 with BOM for Excel
- csv_bytes = '\ufeff'.encode('utf-8') + output.getvalue().encode('utf-8')
- zf.writestr(f"data/{filename}", csv_bytes)
-
- # Create ZIP
- zip_buffer = io.BytesIO()
- export_date = datetime.now().strftime('%Y-%m-%d')
- profile_name = prof.get('name', 'export')
-
- with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
- with get_db() as conn:
- cur = get_cursor(conn)
-
- # 1. README.txt
- readme = f"""Mitai Jinkendo – Datenexport
-Version: 2
-Exportiert am: {export_date}
-Profil: {profile_name}
-
-Inhalt:
-- profile.json: Profildaten und Einstellungen
-- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8)
-- insights/: KI-Auswertungen (JSON)
-- photos/: Progress-Fotos (JPEG)
-
-Import:
-Dieser Export kann in Mitai Jinkendo unter
-Einstellungen → Import → "Mitai Backup importieren"
-wieder eingespielt werden.
-
-Format-Version 2 (ab v9b):
-Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
-Trennzeichen: Semikolon (;)
-Datumsformat: YYYY-MM-DD
-"""
- zf.writestr("README.txt", readme.encode('utf-8'))
-
- # 2. profile.json (ohne Passwort-Hash)
- cur.execute("SELECT COUNT(*) as c FROM weight_log WHERE profile_id=%s", (pid,))
- w_count = cur.fetchone()['c']
- cur.execute("SELECT COUNT(*) as c FROM nutrition_log WHERE profile_id=%s", (pid,))
- n_count = cur.fetchone()['c']
- cur.execute("SELECT COUNT(*) as c FROM activity_log WHERE profile_id=%s", (pid,))
- a_count = cur.fetchone()['c']
- cur.execute("SELECT COUNT(*) as c FROM photos WHERE profile_id=%s", (pid,))
- p_count = cur.fetchone()['c']
-
- profile_data = {
- "export_version": "2",
- "export_date": export_date,
- "app": "Mitai Jinkendo",
- "profile": {
- "name": prof.get('name'),
- "email": prof.get('email'),
- "sex": prof.get('sex'),
- "height": float(prof['height']) if prof.get('height') else None,
- "birth_year": prof['dob'].year if prof.get('dob') else None,
- "goal_weight": float(prof['goal_weight']) if prof.get('goal_weight') else None,
- "goal_bf_pct": float(prof['goal_bf_pct']) if prof.get('goal_bf_pct') else None,
- "avatar_color": prof.get('avatar_color'),
- "auth_type": prof.get('auth_type'),
- "session_days": prof.get('session_days'),
- "ai_enabled": prof.get('ai_enabled'),
- "tier": prof.get('tier')
- },
- "stats": {
- "weight_entries": w_count,
- "nutrition_entries": n_count,
- "activity_entries": a_count,
- "photos": p_count
- }
- }
- zf.writestr("profile.json", json.dumps(profile_data, indent=2, ensure_ascii=False).encode('utf-8'))
-
- # 3. data/weight.csv
- cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
- write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()],
- ['id','date','weight','note','source','created'])
-
- # 4. data/circumferences.csv
- cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
- rows = [r2d(r) for r in cur.fetchall()]
- # Rename columns to match spec
- for r in rows:
- r['waist'] = r.pop('c_waist', None)
- r['hip'] = r.pop('c_hip', None)
- r['chest'] = r.pop('c_chest', None)
- r['neck'] = r.pop('c_neck', None)
- r['upper_arm'] = r.pop('c_arm', None)
- r['thigh'] = r.pop('c_thigh', None)
- r['calf'] = r.pop('c_calf', None)
- r['forearm'] = None # not tracked
- r['note'] = r.pop('notes', None)
- write_csv(zf, "circumferences.csv", rows,
- ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created'])
-
- # 5. data/caliper.csv
- cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
- rows = [r2d(r) for r in cur.fetchall()]
- for r in rows:
- r['chest'] = r.pop('sf_chest', None)
- r['abdomen'] = r.pop('sf_abdomen', None)
- r['thigh'] = r.pop('sf_thigh', None)
- r['tricep'] = r.pop('sf_triceps', None)
- r['subscapular'] = r.pop('sf_subscap', None)
- r['suprailiac'] = r.pop('sf_suprailiac', None)
- r['midaxillary'] = r.pop('sf_axilla', None)
- r['method'] = r.pop('sf_method', None)
- r['bf_percent'] = r.pop('body_fat_pct', None)
- r['note'] = r.pop('notes', None)
- write_csv(zf, "caliper.csv", rows,
- ['id','date','chest','abdomen','thigh','tricep','subscapular','suprailiac','midaxillary','method','bf_percent','note','created'])
-
- # 6. data/nutrition.csv
- cur.execute("SELECT id, date, kcal, protein_g, fat_g, carbs_g, source, created FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
- rows = [r2d(r) for r in cur.fetchall()]
- for r in rows:
- r['meal_name'] = '' # not tracked per meal
- r['protein'] = r.pop('protein_g', None)
- r['fat'] = r.pop('fat_g', None)
- r['carbs'] = r.pop('carbs_g', None)
- r['fiber'] = None # not tracked
- r['note'] = ''
- write_csv(zf, "nutrition.csv", rows,
- ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
-
- # 7. data/activity.csv
- cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
- rows = [r2d(r) for r in cur.fetchall()]
- for r in rows:
- r['name'] = r['activity_type']
- r['type'] = r.pop('activity_type', None)
- r['kcal'] = r.pop('kcal_active', None)
- r['heart_rate_avg'] = r.pop('hr_avg', None)
- r['heart_rate_max'] = r.pop('hr_max', None)
- r['note'] = r.pop('notes', None)
- write_csv(zf, "activity.csv", rows,
- ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created'])
-
- # 8. insights/ai_insights.json
- cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
- insights = []
- for r in cur.fetchall():
- rd = r2d(r)
- insights.append({
- "id": rd['id'],
- "scope": rd['scope'],
- "created": rd['created'].isoformat() if hasattr(rd['created'], 'isoformat') else str(rd['created']),
- "result": rd['content']
- })
- if insights:
- zf.writestr("insights/ai_insights.json", json.dumps(insights, indent=2, ensure_ascii=False).encode('utf-8'))
-
- # 9. photos/
- cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
- photos = [r2d(r) for r in cur.fetchall()]
- for i, photo in enumerate(photos):
- photo_path = Path(PHOTOS_DIR) / photo['path']
- if photo_path.exists():
- filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}"
- zf.write(photo_path, f"photos/{filename}")
-
- zip_buffer.seek(0)
- filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
- return StreamingResponse(
- iter([zip_buffer.getvalue()]),
- media_type="application/zip",
- headers={"Content-Disposition": f"attachment; filename={filename}"}
- )
-
-
-# ── Import ZIP ──────────────────────────────────────────────────
-@app.post("/api/import/zip")
-async def import_zip(
- file: UploadFile = File(...),
- x_profile_id: Optional[str] = Header(default=None),
- session: dict = Depends(require_auth)
-):
- """
- Import data from ZIP export file.
-
- - Validates export format
- - Imports missing entries only (ON CONFLICT DO NOTHING)
- - Imports photos
- - Returns import summary
- - Full rollback on error
- """
- pid = get_pid(x_profile_id)
-
- # Read uploaded file
- content = await file.read()
- zip_buffer = io.BytesIO(content)
-
- try:
- with zipfile.ZipFile(zip_buffer, 'r') as zf:
- # 1. Validate profile.json
- if 'profile.json' not in zf.namelist():
- raise HTTPException(400, "Ungültiger Export: profile.json fehlt")
-
- profile_data = json.loads(zf.read('profile.json').decode('utf-8'))
- export_version = profile_data.get('export_version', '1')
-
- # Stats tracker
- stats = {
- 'weight': 0,
- 'circumferences': 0,
- 'caliper': 0,
- 'nutrition': 0,
- 'activity': 0,
- 'photos': 0,
- 'insights': 0
- }
-
- with get_db() as conn:
- cur = get_cursor(conn)
-
- try:
- # 2. Import weight.csv
- if 'data/weight.csv' in zf.namelist():
- csv_data = zf.read('data/weight.csv').decode('utf-8-sig')
- reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
- for row in reader:
- cur.execute("""
- INSERT INTO weight_log (profile_id, date, weight, note, source, created)
- VALUES (%s, %s, %s, %s, %s, %s)
- ON CONFLICT (profile_id, date) DO NOTHING
- """, (
- pid,
- row['date'],
- float(row['weight']) if row['weight'] else None,
- row.get('note', ''),
- row.get('source', 'import'),
- row.get('created', datetime.now())
- ))
- if cur.rowcount > 0:
- stats['weight'] += 1
-
- # 3. Import circumferences.csv
- if 'data/circumferences.csv' in zf.namelist():
- csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig')
- reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
- for row in reader:
- # Map CSV columns to DB columns
- cur.execute("""
- INSERT INTO circumference_log (
- profile_id, date, c_waist, c_hip, c_chest, c_neck,
- c_arm, c_thigh, c_calf, notes, created
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- ON CONFLICT (profile_id, date) DO NOTHING
- """, (
- pid,
- row['date'],
- float(row['waist']) if row.get('waist') else None,
- float(row['hip']) if row.get('hip') else None,
- float(row['chest']) if row.get('chest') else None,
- float(row['neck']) if row.get('neck') else None,
- float(row['upper_arm']) if row.get('upper_arm') else None,
- float(row['thigh']) if row.get('thigh') else None,
- float(row['calf']) if row.get('calf') else None,
- row.get('note', ''),
- row.get('created', datetime.now())
- ))
- if cur.rowcount > 0:
- stats['circumferences'] += 1
-
- # 4. Import caliper.csv
- if 'data/caliper.csv' in zf.namelist():
- csv_data = zf.read('data/caliper.csv').decode('utf-8-sig')
- reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
- for row in reader:
- cur.execute("""
- INSERT INTO caliper_log (
- profile_id, date, sf_chest, sf_abdomen, sf_thigh,
- sf_triceps, sf_subscap, sf_suprailiac, sf_axilla,
- sf_method, body_fat_pct, notes, created
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- ON CONFLICT (profile_id, date) DO NOTHING
- """, (
- pid,
- row['date'],
- float(row['chest']) if row.get('chest') else None,
- float(row['abdomen']) if row.get('abdomen') else None,
- float(row['thigh']) if row.get('thigh') else None,
- float(row['tricep']) if row.get('tricep') else None,
- float(row['subscapular']) if row.get('subscapular') else None,
- float(row['suprailiac']) if row.get('suprailiac') else None,
- float(row['midaxillary']) if row.get('midaxillary') else None,
- row.get('method', 'jackson3'),
- float(row['bf_percent']) if row.get('bf_percent') else None,
- row.get('note', ''),
- row.get('created', datetime.now())
- ))
- if cur.rowcount > 0:
- stats['caliper'] += 1
-
- # 5. Import nutrition.csv
- if 'data/nutrition.csv' in zf.namelist():
- csv_data = zf.read('data/nutrition.csv').decode('utf-8-sig')
- reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
- for row in reader:
- cur.execute("""
- INSERT INTO nutrition_log (
- profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
- ON CONFLICT (profile_id, date) DO NOTHING
- """, (
- pid,
- row['date'],
- float(row['kcal']) if row.get('kcal') else None,
- float(row['protein']) if row.get('protein') else None,
- float(row['fat']) if row.get('fat') else None,
- float(row['carbs']) if row.get('carbs') else None,
- row.get('source', 'import'),
- row.get('created', datetime.now())
- ))
- if cur.rowcount > 0:
- stats['nutrition'] += 1
-
- # 6. Import activity.csv
- if 'data/activity.csv' in zf.namelist():
- csv_data = zf.read('data/activity.csv').decode('utf-8-sig')
- reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
- for row in reader:
- cur.execute("""
- INSERT INTO activity_log (
- profile_id, date, activity_type, duration_min,
- kcal_active, hr_avg, hr_max, distance_km, notes, source, created
- )
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- """, (
- pid,
- row['date'],
- row.get('type', 'Training'),
- float(row['duration_min']) if row.get('duration_min') else None,
- float(row['kcal']) if row.get('kcal') else None,
- float(row['heart_rate_avg']) if row.get('heart_rate_avg') else None,
- float(row['heart_rate_max']) if row.get('heart_rate_max') else None,
- float(row['distance_km']) if row.get('distance_km') else None,
- row.get('note', ''),
- row.get('source', 'import'),
- row.get('created', datetime.now())
- ))
- if cur.rowcount > 0:
- stats['activity'] += 1
-
- # 7. Import ai_insights.json
- if 'insights/ai_insights.json' in zf.namelist():
- insights_data = json.loads(zf.read('insights/ai_insights.json').decode('utf-8'))
- for insight in insights_data:
- cur.execute("""
- INSERT INTO ai_insights (profile_id, scope, content, created)
- VALUES (%s, %s, %s, %s)
- """, (
- pid,
- insight['scope'],
- insight['result'],
- insight.get('created', datetime.now())
- ))
- stats['insights'] += 1
-
- # 8. Import photos
- photo_files = [f for f in zf.namelist() if f.startswith('photos/') and not f.endswith('/')]
- for photo_file in photo_files:
- # Extract date from filename (format: YYYY-MM-DD_N.jpg)
- filename = Path(photo_file).name
- parts = filename.split('_')
- photo_date = parts[0] if len(parts) > 0 else datetime.now().strftime('%Y-%m-%d')
-
- # Generate new ID and path
- photo_id = str(uuid.uuid4())
- ext = Path(filename).suffix
- new_filename = f"{photo_id}{ext}"
- target_path = PHOTOS_DIR / new_filename
-
- # Check if photo already exists for this date
- cur.execute("""
- SELECT id FROM photos
- WHERE profile_id = %s AND date = %s
- """, (pid, photo_date))
-
- if cur.fetchone() is None:
- # Write photo file
- with open(target_path, 'wb') as f:
- f.write(zf.read(photo_file))
-
- # Insert DB record
- cur.execute("""
- INSERT INTO photos (id, profile_id, date, path, created)
- VALUES (%s, %s, %s, %s, %s)
- """, (photo_id, pid, photo_date, new_filename, datetime.now()))
- stats['photos'] += 1
-
- # Commit transaction
- conn.commit()
-
- except Exception as e:
- # Rollback on any error
- conn.rollback()
- raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
-
- return {
- "ok": True,
- "message": "Import erfolgreich",
- "stats": stats,
- "total": sum(stats.values())
- }
-
- except zipfile.BadZipFile:
- raise HTTPException(400, "Ungültige ZIP-Datei")
- except Exception as e:
- raise HTTPException(500, f"Import-Fehler: {str(e)}")
diff --git a/backend/main_old.py b/backend/main_old.py
new file mode 100644
index 0000000..11bb183
--- /dev/null
+++ b/backend/main_old.py
@@ -0,0 +1,1878 @@
+import os, csv, io, uuid, json, zipfile
+from pathlib import Path
+from typing import Optional
+from datetime import datetime
+from decimal import Decimal
+
+from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Depends
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse, FileResponse, Response
+from pydantic import BaseModel
+import aiofiles
+import bcrypt
+from slowapi import Limiter, _rate_limit_exceeded_handler
+from slowapi.util import get_remote_address
+from slowapi.errors import RateLimitExceeded
+from starlette.requests import Request
+
+from db import get_db, get_cursor, r2d, init_db
+from auth import hash_pin, verify_pin, make_token, get_session, require_auth, require_auth_flexible, require_admin
+from models import (
+ ProfileCreate, ProfileUpdate, WeightEntry, CircumferenceEntry,
+ CaliperEntry, ActivityEntry, NutritionDay, LoginRequest,
+ PasswordResetRequest, PasswordResetConfirm, AdminProfileUpdate
+)
+
+DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
+PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
+DATA_DIR.mkdir(parents=True, exist_ok=True)
+PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
+
+OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
+OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
+ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
+
+app = FastAPI(title="Mitai Jinkendo API", version="3.0.0")
+limiter = Limiter(key_func=get_remote_address)
+app.state.limiter = limiter
+app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
+ allow_credentials=True,
+ allow_methods=["GET","POST","PUT","DELETE","OPTIONS"],
+ allow_headers=["*"],
+)
+
+AVATAR_COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
+
+@app.on_event("startup")
+async def startup_event():
+ """Run migrations and initialization on startup."""
+ try:
+ init_db()
+ except Exception as e:
+ print(f"⚠️ init_db() failed (non-fatal): {e}")
+ # Don't crash on startup - pipeline prompt can be created manually
+
+# ── Helper: get profile_id from header ───────────────────────────────────────
+def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
+ """Get profile_id - from header for legacy endpoints."""
+ if x_profile_id:
+ return x_profile_id
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
+ row = cur.fetchone()
+ if row: return row['id']
+ raise HTTPException(400, "Kein Profil gefunden")
+
+# ── Models ────────────────────────────────────────────────────────────────────
+# ── Profiles ──────────────────────────────────────────────────────────────────
+from datetime import timedelta
+
+# Models moved to models.py
+# Auth functions moved to auth.py
+
+@app.get("/api/profiles")
+def list_profiles(session=Depends(require_auth)):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles ORDER BY created")
+ rows = cur.fetchall()
+ return [r2d(r) for r in rows]
+
+@app.post("/api/profiles")
+def create_profile(p: ProfileCreate, session=Depends(require_auth)):
+ pid = str(uuid.uuid4())
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""",
+ (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct))
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ return r2d(cur.fetchone())
+
+@app.get("/api/profiles/{pid}")
+def get_profile(pid: str, session=Depends(require_auth)):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ row = cur.fetchone()
+ if not row: raise HTTPException(404, "Profil nicht gefunden")
+ return r2d(row)
+
+@app.put("/api/profiles/{pid}")
+def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
+ with get_db() as conn:
+ data = {k:v for k,v in p.model_dump().items() if v is not None}
+ data['updated'] = datetime.now().isoformat()
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s",
+ list(data.values())+[pid])
+ return get_profile(pid, session)
+
+@app.delete("/api/profiles/{pid}")
+def delete_profile(pid: str, session=Depends(require_auth)):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT COUNT(*) as count FROM profiles")
+ count = cur.fetchone()['count']
+ if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
+ for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']:
+ cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
+ cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
+ return {"ok": True}
+
+@app.get("/api/profile")
+def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
+ """Legacy endpoint – returns active profile."""
+ pid = get_pid(x_profile_id)
+ return get_profile(pid, session)
+
+@app.put("/api/profile")
+def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ return update_profile(pid, p, session)
+
+# ── Weight ────────────────────────────────────────────────────────────────────
+@app.get("/api/weight")
+def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+@app.post("/api/weight")
+def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
+ ex = cur.fetchone()
+ if ex:
+ cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id']))
+ wid = ex['id']
+ else:
+ wid = str(uuid.uuid4())
+ cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)",
+ (wid,pid,e.date,e.weight,e.note))
+ return {"id":wid,"date":e.date,"weight":e.weight}
+
+@app.put("/api/weight/{wid}")
+def update_weight(wid: str, e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("UPDATE weight_log SET date=%s,weight=%s,note=%s WHERE id=%s AND profile_id=%s",
+ (e.date,e.weight,e.note,wid,pid))
+ return {"id":wid}
+
+@app.delete("/api/weight/{wid}")
+def delete_weight(wid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM weight_log WHERE id=%s AND profile_id=%s", (wid,pid))
+ return {"ok":True}
+
+@app.get("/api/weight/stats")
+def weight_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
+ rows = cur.fetchall()
+ if not rows: return {"count":0,"latest":None,"prev":None,"min":None,"max":None,"avg_7d":None}
+ w=[float(r['weight']) for r in rows]
+ return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":float(rows[0]['weight'])},
+ "prev":{"date":rows[1]['date'],"weight":float(rows[1]['weight'])} if len(rows)>1 else None,
+ "min":min(w),"max":max(w),"avg_7d":round(sum(w[:7])/min(7,len(w)),2)}
+
+# ── Circumferences ────────────────────────────────────────────────────────────
+@app.get("/api/circumferences")
+def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+@app.post("/api/circumferences")
+def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
+ ex = cur.fetchone()
+ d = e.model_dump()
+ if ex:
+ eid = ex['id']
+ sets = ', '.join(f"{k}=%s" for k in d if k!='date')
+ cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
+ [v for k,v in d.items() if k!='date']+[eid])
+ else:
+ eid = str(uuid.uuid4())
+ cur.execute("""INSERT INTO circumference_log
+ (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
+ (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
+ d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
+ return {"id":eid,"date":e.date}
+
+@app.put("/api/circumferences/{eid}")
+def update_circ(eid: str, e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ d = e.model_dump()
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE circumference_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
+ list(d.values())+[eid,pid])
+ return {"id":eid}
+
+@app.delete("/api/circumferences/{eid}")
+def delete_circ(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM circumference_log WHERE id=%s AND profile_id=%s", (eid,pid))
+ return {"ok":True}
+
+# ── Caliper ───────────────────────────────────────────────────────────────────
+@app.get("/api/caliper")
+def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+@app.post("/api/caliper")
+def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
+ ex = cur.fetchone()
+ d = e.model_dump()
+ if ex:
+ eid = ex['id']
+ sets = ', '.join(f"{k}=%s" for k in d if k!='date')
+ cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
+ [v for k,v in d.items() if k!='date']+[eid])
+ else:
+ eid = str(uuid.uuid4())
+ cur.execute("""INSERT INTO caliper_log
+ (id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
+ sf_abdomen,sf_thigh,sf_calf_med,sf_lowerback,sf_biceps,body_fat_pct,lean_mass,fat_mass,notes,created)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
+ (eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'],
+ d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'],
+ d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes']))
+ return {"id":eid,"date":e.date}
+
+@app.put("/api/caliper/{eid}")
+def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ d = e.model_dump()
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE caliper_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
+ list(d.values())+[eid,pid])
+ return {"id":eid}
+
+@app.delete("/api/caliper/{eid}")
+def delete_caliper(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM caliper_log WHERE id=%s AND profile_id=%s", (eid,pid))
+ return {"ok":True}
+
+# ── Activity ──────────────────────────────────────────────────────────────────
+@app.get("/api/activity")
+def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC, start_time DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+@app.post("/api/activity")
+def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ eid = str(uuid.uuid4())
+ d = e.model_dump()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("""INSERT INTO activity_log
+ (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
+ hr_avg,hr_max,distance_km,rpe,source,notes,created)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
+ (eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
+ d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
+ d['rpe'],d['source'],d['notes']))
+ return {"id":eid,"date":e.date}
+
+@app.put("/api/activity/{eid}")
+def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ d = e.model_dump()
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
+ list(d.values())+[eid,pid])
+ return {"id":eid}
+
+@app.delete("/api/activity/{eid}")
+def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM activity_log WHERE id=%s AND profile_id=%s", (eid,pid))
+ return {"ok":True}
+
+@app.get("/api/activity/stats")
+def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
+ total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
+ total_min=sum(float(r.get('duration_min') or 0) for r in rows)
+ by_type={}
+ for r in rows:
+ t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
+ by_type[t]['count']+=1
+ by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
+ by_type[t]['min']+=float(r.get('duration_min') or 0)
+ return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
+
+@app.post("/api/activity/import-csv")
+async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ raw = await file.read()
+ try: text = raw.decode('utf-8')
+ except: text = raw.decode('latin-1')
+ if text.startswith('\ufeff'): text = text[1:]
+ if not text.strip(): raise HTTPException(400,"Leere Datei")
+ reader = csv.DictReader(io.StringIO(text))
+ inserted = skipped = 0
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ for row in reader:
+ wtype = row.get('Workout Type','').strip()
+ start = row.get('Start','').strip()
+ if not wtype or not start: continue
+ try: date = start[:10]
+ except: continue
+ dur = row.get('Duration','').strip()
+ duration_min = None
+ if dur:
+ try:
+ p = dur.split(':')
+ duration_min = round(int(p[0])*60+int(p[1])+int(p[2])/60,1)
+ except: pass
+ def kj(v):
+ try: return round(float(v)/4.184) if v else None
+ except: return None
+ def tf(v):
+ try: return round(float(v),1) if v else None
+ except: return None
+ try:
+ cur.execute("""INSERT INTO activity_log
+ (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
+ hr_avg,hr_max,distance_km,source,created)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""",
+ (str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
+ kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
+ tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
+ tf(row.get('Max. Herzfrequenz (count/min)','')),
+ tf(row.get('Distanz (km)',''))))
+ inserted+=1
+ except: skipped+=1
+ return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
+
+# ── Photos ────────────────────────────────────────────────────────────────────
+@app.post("/api/photos")
+async def upload_photo(file: UploadFile=File(...), date: str="",
+ x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ fid = str(uuid.uuid4())
+ ext = Path(file.filename).suffix or '.jpg'
+ path = PHOTOS_DIR / f"{fid}{ext}"
+ async with aiofiles.open(path,'wb') as f: await f.write(await file.read())
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
+ (fid,pid,date,str(path)))
+ return {"id":fid,"date":date}
+
+@app.get("/api/photos/{fid}")
+def get_photo(fid: str, session: dict=Depends(require_auth_flexible)):
+ """Get photo by ID. Auth via header or query param (for
tags)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT path FROM photos WHERE id=%s", (fid,))
+ row = cur.fetchone()
+ if not row: raise HTTPException(404, "Photo not found")
+ photo_path = Path(PHOTOS_DIR) / row['path']
+ if not photo_path.exists():
+ raise HTTPException(404, "Photo file not found")
+ return FileResponse(photo_path)
+
+@app.get("/api/photos")
+def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,))
+ return [r2d(r) for r in cur.fetchall()]
+
+# ── Nutrition ─────────────────────────────────────────────────────────────────
+def _pf(s):
+ try: return float(str(s).replace(',','.').strip())
+ except: return 0.0
+
+@app.post("/api/nutrition/import-csv")
+async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ raw = await file.read()
+ try: text = raw.decode('utf-8')
+ except: text = raw.decode('latin-1')
+ if text.startswith('\ufeff'): text = text[1:]
+ if not text.strip(): raise HTTPException(400,"Leere Datei")
+ reader = csv.DictReader(io.StringIO(text), delimiter=';')
+ days: dict = {}
+ count = 0
+ for row in reader:
+ rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
+ if not rd: continue
+ try:
+ p = rd.split(' ')[0].split('.')
+ iso = f"{p[2]}-{p[1]}-{p[0]}"
+ except: continue
+ days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0})
+ days[iso]['kcal'] += _pf(row.get('kj',0))/4.184
+ days[iso]['fat_g'] += _pf(row.get('fett_g',0))
+ days[iso]['carbs_g'] += _pf(row.get('kh_g',0))
+ days[iso]['protein_g'] += _pf(row.get('protein_g',0))
+ count+=1
+ inserted=0
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ for iso,vals in days.items():
+ kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
+ carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
+ cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
+ if cur.fetchone():
+ cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
+ (kcal,prot,fat,carbs,pid,iso))
+ else:
+ cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
+ (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
+ inserted+=1
+ return {"rows_parsed":count,"days_imported":inserted,
+ "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
+
+@app.get("/api/nutrition")
+def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+@app.get("/api/nutrition/correlations")
+def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date",(pid,))
+ nutr={r['date']:r2d(r) for r in cur.fetchall()}
+ cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,))
+ wlog={r['date']:r['weight'] for r in cur.fetchall()}
+ cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,))
+ cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date'])
+ all_dates=sorted(set(list(nutr)+list(wlog)))
+ mi,last_cal,cal_by_date=0,{},{}
+ for d in all_dates:
+ while mi= limit:
+ raise HTTPException(429, f"Tägliches KI-Limit erreicht ({limit} Calls)")
+ return (True, limit, used)
+
+def inc_ai_usage(pid: str):
+ """Increment AI usage counter for today."""
+ today = datetime.now().date().isoformat()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id, call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
+ row = cur.fetchone()
+ if row:
+ cur.execute("UPDATE ai_usage SET call_count=%s WHERE id=%s", (row['call_count']+1, row['id']))
+ else:
+ cur.execute("INSERT INTO ai_usage (id, profile_id, date, call_count) VALUES (%s,%s,%s,1)",
+ (str(uuid.uuid4()), pid, today))
+
+def _get_profile_data(pid: str):
+ """Fetch all relevant data for AI analysis."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ prof = r2d(cur.fetchone())
+ cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
+ weight = [r2d(r) for r in cur.fetchall()]
+ cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
+ circ = [r2d(r) for r in cur.fetchall()]
+ cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
+ caliper = [r2d(r) for r in cur.fetchall()]
+ cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
+ nutrition = [r2d(r) for r in cur.fetchall()]
+ cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
+ activity = [r2d(r) for r in cur.fetchall()]
+ return {
+ "profile": prof,
+ "weight": weight,
+ "circumference": circ,
+ "caliper": caliper,
+ "nutrition": nutrition,
+ "activity": activity
+ }
+
+def _render_template(template: str, data: dict) -> str:
+ """Simple template variable replacement."""
+ result = template
+ for k, v in data.items():
+ result = result.replace(f"{{{{{k}}}}}", str(v) if v is not None else "")
+ return result
+
+def _prepare_template_vars(data: dict) -> dict:
+ """Prepare template variables from profile data."""
+ prof = data['profile']
+ weight = data['weight']
+ circ = data['circumference']
+ caliper = data['caliper']
+ nutrition = data['nutrition']
+ activity = data['activity']
+
+ vars = {
+ "name": prof.get('name', 'Nutzer'),
+ "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich",
+ "height": prof.get('height', 178),
+ "goal_weight": float(prof.get('goal_weight')) if prof.get('goal_weight') else "nicht gesetzt",
+ "goal_bf_pct": float(prof.get('goal_bf_pct')) if prof.get('goal_bf_pct') else "nicht gesetzt",
+ "weight_aktuell": float(weight[0]['weight']) if weight else "keine Daten",
+ "kf_aktuell": float(caliper[0]['body_fat_pct']) if caliper and caliper[0].get('body_fat_pct') else "unbekannt",
+ }
+
+ # Calculate age from dob
+ if prof.get('dob'):
+ try:
+ from datetime import date
+ dob = datetime.strptime(prof['dob'], '%Y-%m-%d').date()
+ today = date.today()
+ age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
+ vars['age'] = age
+ except:
+ vars['age'] = "unbekannt"
+ else:
+ vars['age'] = "unbekannt"
+
+ # Weight trend summary
+ if len(weight) >= 2:
+ recent = weight[:30]
+ delta = float(recent[0]['weight']) - float(recent[-1]['weight'])
+ vars['weight_trend'] = f"{len(recent)} Einträge, Δ30d: {delta:+.1f}kg"
+ else:
+ vars['weight_trend'] = "zu wenig Daten"
+
+ # Caliper summary
+ if caliper:
+ c = caliper[0]
+ bf = float(c.get('body_fat_pct')) if c.get('body_fat_pct') else '?'
+ vars['caliper_summary'] = f"KF: {bf}%, Methode: {c.get('sf_method','?')}"
+ else:
+ vars['caliper_summary'] = "keine Daten"
+
+ # Circumference summary
+ if circ:
+ c = circ[0]
+ parts = []
+ for k in ['c_waist', 'c_belly', 'c_hip']:
+ if c.get(k): parts.append(f"{k.split('_')[1]}: {float(c[k])}cm")
+ vars['circ_summary'] = ", ".join(parts) if parts else "keine Daten"
+ else:
+ vars['circ_summary'] = "keine Daten"
+
+ # Nutrition summary
+ if nutrition:
+ n = len(nutrition)
+ avg_kcal = sum(float(d.get('kcal',0) or 0) for d in nutrition) / n
+ avg_prot = sum(float(d.get('protein_g',0) or 0) for d in nutrition) / n
+ vars['nutrition_summary'] = f"{n} Tage, Ø {avg_kcal:.0f}kcal, {avg_prot:.0f}g Protein"
+ vars['nutrition_detail'] = vars['nutrition_summary']
+ vars['nutrition_days'] = n
+ vars['kcal_avg'] = round(avg_kcal)
+ vars['protein_avg'] = round(avg_prot,1)
+ vars['fat_avg'] = round(sum(float(d.get('fat_g',0) or 0) for d in nutrition) / n,1)
+ vars['carb_avg'] = round(sum(float(d.get('carbs_g',0) or 0) for d in nutrition) / n,1)
+ else:
+ vars['nutrition_summary'] = "keine Daten"
+ vars['nutrition_detail'] = "keine Daten"
+ vars['nutrition_days'] = 0
+ vars['kcal_avg'] = 0
+ vars['protein_avg'] = 0
+ vars['fat_avg'] = 0
+ vars['carb_avg'] = 0
+
+ # Protein targets
+ w = weight[0]['weight'] if weight else prof.get('height',178) - 100
+ w = float(w) # Convert Decimal to float for math operations
+ vars['protein_ziel_low'] = round(w * 1.6)
+ vars['protein_ziel_high'] = round(w * 2.2)
+
+ # Activity summary
+ if activity:
+ n = len(activity)
+ total_kcal = sum(float(a.get('kcal_active',0) or 0) for a in activity)
+ vars['activity_summary'] = f"{n} Trainings, {total_kcal:.0f}kcal gesamt"
+ vars['activity_detail'] = vars['activity_summary']
+ vars['activity_kcal_summary'] = f"Ø {total_kcal/n:.0f}kcal/Training"
+ else:
+ vars['activity_summary'] = "keine Daten"
+ vars['activity_detail'] = "keine Daten"
+ vars['activity_kcal_summary'] = "keine Daten"
+
+ return vars
+
+@app.post("/api/insights/run/{slug}")
+async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Run AI analysis with specified prompt template."""
+ pid = get_pid(x_profile_id)
+ check_ai_limit(pid)
+
+ # Get prompt template
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=true", (slug,))
+ prompt_row = cur.fetchone()
+ if not prompt_row:
+ raise HTTPException(404, f"Prompt '{slug}' nicht gefunden")
+
+ prompt_tmpl = prompt_row['template']
+ data = _get_profile_data(pid)
+ vars = _prepare_template_vars(data)
+ final_prompt = _render_template(prompt_tmpl, vars)
+
+ # Call AI
+ if ANTHROPIC_KEY:
+ # Use Anthropic SDK
+ import anthropic
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
+ response = client.messages.create(
+ model="claude-sonnet-4-20250514",
+ max_tokens=2000,
+ messages=[{"role": "user", "content": final_prompt}]
+ )
+ content = response.content[0].text
+ elif OPENROUTER_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
+ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
+ json={
+ "model": OPENROUTER_MODEL,
+ "messages": [{"role": "user", "content": final_prompt}],
+ "max_tokens": 2000
+ },
+ timeout=60.0
+ )
+ if resp.status_code != 200:
+ raise HTTPException(500, f"KI-Fehler: {resp.text}")
+ content = resp.json()['choices'][0]['message']['content']
+ else:
+ raise HTTPException(500, "Keine KI-API konfiguriert")
+
+ # Save insight
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug))
+ cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
+ (str(uuid.uuid4()), pid, slug, content))
+
+ inc_ai_usage(pid)
+ return {"scope": slug, "content": content}
+
+@app.post("/api/insights/pipeline")
+async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Run 3-stage pipeline analysis."""
+ pid = get_pid(x_profile_id)
+ check_ai_limit(pid)
+
+ data = _get_profile_data(pid)
+ vars = _prepare_template_vars(data)
+
+ # Stage 1: Parallel JSON analyses
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT slug, template FROM ai_prompts WHERE slug LIKE 'pipeline_%' AND slug NOT IN ('pipeline_synthesis','pipeline_goals') AND active=true")
+ stage1_prompts = [r2d(r) for r in cur.fetchall()]
+
+ stage1_results = {}
+ for p in stage1_prompts:
+ slug = p['slug']
+ final_prompt = _render_template(p['template'], vars)
+
+ if ANTHROPIC_KEY:
+ import anthropic
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
+ response = client.messages.create(
+ model="claude-sonnet-4-20250514",
+ max_tokens=1000,
+ messages=[{"role": "user", "content": final_prompt}]
+ )
+ content = response.content[0].text.strip()
+ elif OPENROUTER_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
+ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
+ json={
+ "model": OPENROUTER_MODEL,
+ "messages": [{"role": "user", "content": final_prompt}],
+ "max_tokens": 1000
+ },
+ timeout=60.0
+ )
+ content = resp.json()['choices'][0]['message']['content'].strip()
+ else:
+ raise HTTPException(500, "Keine KI-API konfiguriert")
+
+ # Try to parse JSON, fallback to raw text
+ try:
+ stage1_results[slug] = json.loads(content)
+ except:
+ stage1_results[slug] = content
+
+ # Stage 2: Synthesis
+ vars['stage1_body'] = json.dumps(stage1_results.get('pipeline_body', {}), ensure_ascii=False)
+ vars['stage1_nutrition'] = json.dumps(stage1_results.get('pipeline_nutrition', {}), ensure_ascii=False)
+ vars['stage1_activity'] = json.dumps(stage1_results.get('pipeline_activity', {}), ensure_ascii=False)
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=true")
+ synth_row = cur.fetchone()
+ if not synth_row:
+ raise HTTPException(500, "Pipeline synthesis prompt not found")
+
+ synth_prompt = _render_template(synth_row['template'], vars)
+
+ if ANTHROPIC_KEY:
+ import anthropic
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
+ response = client.messages.create(
+ model="claude-sonnet-4-20250514",
+ max_tokens=2000,
+ messages=[{"role": "user", "content": synth_prompt}]
+ )
+ synthesis = response.content[0].text
+ elif OPENROUTER_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
+ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
+ json={
+ "model": OPENROUTER_MODEL,
+ "messages": [{"role": "user", "content": synth_prompt}],
+ "max_tokens": 2000
+ },
+ timeout=60.0
+ )
+ synthesis = resp.json()['choices'][0]['message']['content']
+ else:
+ raise HTTPException(500, "Keine KI-API konfiguriert")
+
+ # Stage 3: Goals (only if goals are set)
+ goals_text = None
+ prof = data['profile']
+ if prof.get('goal_weight') or prof.get('goal_bf_pct'):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_goals' AND active=true")
+ goals_row = cur.fetchone()
+ if goals_row:
+ goals_prompt = _render_template(goals_row['template'], vars)
+
+ if ANTHROPIC_KEY:
+ import anthropic
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
+ response = client.messages.create(
+ model="claude-sonnet-4-20250514",
+ max_tokens=800,
+ messages=[{"role": "user", "content": goals_prompt}]
+ )
+ goals_text = response.content[0].text
+ elif OPENROUTER_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
+ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
+ json={
+ "model": OPENROUTER_MODEL,
+ "messages": [{"role": "user", "content": goals_prompt}],
+ "max_tokens": 800
+ },
+ timeout=60.0
+ )
+ goals_text = resp.json()['choices'][0]['message']['content']
+
+ # Combine synthesis + goals
+ final_content = synthesis
+ if goals_text:
+ final_content += "\n\n" + goals_text
+
+ # Save as 'gesamt' scope
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,))
+ cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
+ (str(uuid.uuid4()), pid, final_content))
+
+ inc_ai_usage(pid)
+ return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}
+
+@app.get("/api/prompts")
+def list_prompts(session: dict=Depends(require_auth)):
+ """
+ List AI prompts.
+ - Admins: see ALL prompts (including pipeline and inactive)
+ - Users: see only active single-analysis prompts
+ """
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ is_admin = session.get('role') == 'admin'
+
+ if is_admin:
+ # Admin sees everything
+ cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug")
+ else:
+ # Users see only active, non-pipeline prompts
+ cur.execute("SELECT * FROM ai_prompts WHERE active=true AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order")
+
+ return [r2d(r) for r in cur.fetchall()]
+
+@app.put("/api/prompts/{prompt_id}")
+def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)):
+ """Update AI prompt template (admin only)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ updates = []
+ values = []
+ if 'name' in data:
+ updates.append('name=%s')
+ values.append(data['name'])
+ if 'description' in data:
+ updates.append('description=%s')
+ values.append(data['description'])
+ if 'template' in data:
+ updates.append('template=%s')
+ values.append(data['template'])
+ if 'active' in data:
+ updates.append('active=%s')
+ # Convert to boolean (accepts true/false, 1/0)
+ values.append(bool(data['active']))
+
+ if updates:
+ cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s",
+ values + [prompt_id])
+
+ return {"ok": True}
+
+@app.get("/api/ai/usage")
+def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get AI usage stats for current profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT ai_limit_day FROM profiles WHERE id=%s", (pid,))
+ prof = cur.fetchone()
+ limit = prof['ai_limit_day'] if prof else None
+
+ today = datetime.now().date().isoformat()
+ cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
+ usage = cur.fetchone()
+ used = usage['call_count'] if usage else 0
+
+ cur.execute("SELECT date, call_count FROM ai_usage WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
+ history = [r2d(r) for r in cur.fetchall()]
+
+ return {
+ "limit": limit,
+ "used_today": used,
+ "remaining": (limit - used) if limit else None,
+ "history": history
+ }
+
+# ── Auth ──────────────────────────────────────────────────────────────────────
+@app.post("/api/auth/login")
+@limiter.limit("5/minute")
+async def login(req: LoginRequest, request: Request):
+ """Login with email + password."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),))
+ prof = cur.fetchone()
+ if not prof:
+ raise HTTPException(401, "Ungültige Zugangsdaten")
+
+ # Verify password
+ if not verify_pin(req.password, prof['pin_hash']):
+ raise HTTPException(401, "Ungültige Zugangsdaten")
+
+ # Auto-upgrade from SHA256 to bcrypt
+ if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'):
+ new_hash = hash_pin(req.password)
+ cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id']))
+
+ # Create session
+ token = make_token()
+ session_days = prof.get('session_days', 30)
+ expires = datetime.now() + timedelta(days=session_days)
+ cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
+ (token, prof['id'], expires.isoformat()))
+
+ return {
+ "token": token,
+ "profile_id": prof['id'],
+ "name": prof['name'],
+ "role": prof['role'],
+ "expires_at": expires.isoformat()
+ }
+
+@app.post("/api/auth/logout")
+def logout(x_auth_token: Optional[str]=Header(default=None)):
+ """Logout (delete session)."""
+ if x_auth_token:
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,))
+ return {"ok": True}
+
+@app.get("/api/auth/me")
+def get_me(session: dict=Depends(require_auth)):
+ """Get current user info."""
+ pid = session['profile_id']
+ return get_profile(pid, session)
+
+@app.get("/api/auth/status")
+def auth_status():
+ """Health check endpoint."""
+ return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"}
+
+@app.put("/api/auth/pin")
+def change_pin(req: dict, session: dict=Depends(require_auth)):
+ """Change PIN/password for current user."""
+ pid = session['profile_id']
+ new_pin = req.get('pin', '')
+ if len(new_pin) < 4:
+ raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
+
+ new_hash = hash_pin(new_pin)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
+
+ return {"ok": True}
+
+@app.post("/api/auth/forgot-password")
+@limiter.limit("3/minute")
+async def password_reset_request(req: PasswordResetRequest, request: Request):
+ """Request password reset email."""
+ email = req.email.lower().strip()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
+ prof = cur.fetchone()
+ if not prof:
+ # Don't reveal if email exists
+ return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
+
+ # Generate reset token
+ token = secrets.token_urlsafe(32)
+ expires = datetime.now() + timedelta(hours=1)
+
+ # Store in sessions table (reuse mechanism)
+ cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
+ (f"reset_{token}", prof['id'], expires.isoformat()))
+
+ # Send email
+ try:
+ import smtplib
+ from email.mime.text import MIMEText
+ smtp_host = os.getenv("SMTP_HOST")
+ smtp_port = int(os.getenv("SMTP_PORT", 587))
+ smtp_user = os.getenv("SMTP_USER")
+ smtp_pass = os.getenv("SMTP_PASS")
+ smtp_from = os.getenv("SMTP_FROM")
+ app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
+
+ if smtp_host and smtp_user and smtp_pass:
+ msg = MIMEText(f"""Hallo {prof['name']},
+
+Du hast einen Passwort-Reset angefordert.
+
+Reset-Link: {app_url}/reset-password?token={token}
+
+Der Link ist 1 Stunde gültig.
+
+Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
+
+Dein Mitai Jinkendo Team
+""")
+ msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo"
+ msg['From'] = smtp_from
+ msg['To'] = email
+
+ with smtplib.SMTP(smtp_host, smtp_port) as server:
+ server.starttls()
+ server.login(smtp_user, smtp_pass)
+ server.send_message(msg)
+ except Exception as e:
+ print(f"Email error: {e}")
+
+ return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
+
+@app.post("/api/auth/reset-password")
+def password_reset_confirm(req: PasswordResetConfirm):
+ """Confirm password reset with token."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP",
+ (f"reset_{req.token}",))
+ sess = cur.fetchone()
+ if not sess:
+ raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
+
+ pid = sess['profile_id']
+ new_hash = hash_pin(req.new_password)
+ cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
+ cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
+
+ return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
+
+# ── Admin ─────────────────────────────────────────────────────────────────────
+@app.get("/api/admin/profiles")
+def admin_list_profiles(session: dict=Depends(require_admin)):
+ """Admin: List all profiles with stats."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles ORDER BY created")
+ profs = [r2d(r) for r in cur.fetchall()]
+
+ for p in profs:
+ pid = p['id']
+ cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s", (pid,))
+ p['weight_count'] = cur.fetchone()['count']
+ cur.execute("SELECT COUNT(*) as count FROM ai_insights WHERE profile_id=%s", (pid,))
+ p['ai_insights_count'] = cur.fetchone()['count']
+
+ today = datetime.now().date().isoformat()
+ cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
+ usage = cur.fetchone()
+ p['ai_usage_today'] = usage['call_count'] if usage else 0
+
+ return profs
+
+@app.put("/api/admin/profiles/{pid}")
+def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depends(require_admin)):
+ """Admin: Update profile settings."""
+ with get_db() as conn:
+ updates = {k:v for k,v in data.model_dump().items() if v is not None}
+ if not updates:
+ return {"ok": True}
+
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in updates)} WHERE id=%s",
+ list(updates.values()) + [pid])
+
+ return {"ok": True}
+
+@app.put("/api/admin/profiles/{pid}/permissions")
+def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)):
+ """Admin: Set profile permissions."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ updates = []
+ values = []
+ if 'ai_enabled' in data:
+ updates.append('ai_enabled=%s')
+ values.append(data['ai_enabled'])
+ if 'ai_limit_day' in data:
+ updates.append('ai_limit_day=%s')
+ values.append(data['ai_limit_day'])
+ if 'export_enabled' in data:
+ updates.append('export_enabled=%s')
+ values.append(data['export_enabled'])
+ if 'role' in data:
+ updates.append('role=%s')
+ values.append(data['role'])
+
+ if updates:
+ cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid])
+
+ return {"ok": True}
+
+@app.put("/api/admin/profiles/{pid}/email")
+def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)):
+ """Admin: Set profile email."""
+ email = data.get('email', '').strip().lower()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid))
+
+ return {"ok": True}
+
+@app.put("/api/admin/profiles/{pid}/pin")
+def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)):
+ """Admin: Set profile PIN/password."""
+ new_pin = data.get('pin', '')
+ if len(new_pin) < 4:
+ raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
+
+ new_hash = hash_pin(new_pin)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
+
+ return {"ok": True}
+
+@app.get("/api/admin/email/status")
+def admin_email_status(session: dict=Depends(require_admin)):
+ """Admin: Check email configuration status."""
+ smtp_host = os.getenv("SMTP_HOST")
+ smtp_user = os.getenv("SMTP_USER")
+ smtp_pass = os.getenv("SMTP_PASS")
+ app_url = os.getenv("APP_URL", "http://localhost:3002")
+
+ configured = bool(smtp_host and smtp_user and smtp_pass)
+
+ return {
+ "configured": configured,
+ "smtp_host": smtp_host or "",
+ "smtp_user": smtp_user or "",
+ "app_url": app_url
+ }
+
+@app.post("/api/admin/email/test")
+def admin_test_email(data: dict, session: dict=Depends(require_admin)):
+ """Admin: Send test email."""
+ email = data.get('to', '')
+ if not email:
+ raise HTTPException(400, "E-Mail-Adresse fehlt")
+
+ try:
+ import smtplib
+ from email.mime.text import MIMEText
+ smtp_host = os.getenv("SMTP_HOST")
+ smtp_port = int(os.getenv("SMTP_PORT", 587))
+ smtp_user = os.getenv("SMTP_USER")
+ smtp_pass = os.getenv("SMTP_PASS")
+ smtp_from = os.getenv("SMTP_FROM")
+
+ if not smtp_host or not smtp_user or not smtp_pass:
+ raise HTTPException(500, "SMTP nicht konfiguriert")
+
+ msg = MIMEText("Dies ist eine Test-E-Mail von Mitai Jinkendo.")
+ msg['Subject'] = "Test-E-Mail"
+ msg['From'] = smtp_from
+ msg['To'] = email
+
+ with smtplib.SMTP(smtp_host, smtp_port) as server:
+ server.starttls()
+ server.login(smtp_user, smtp_pass)
+ server.send_message(msg)
+
+ return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"}
+ except Exception as e:
+ raise HTTPException(500, f"Fehler beim Senden: {str(e)}")
+
+# ── Export ────────────────────────────────────────────────────────────────────
+@app.get("/api/export/csv")
+def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Export all data as CSV."""
+ pid = get_pid(x_profile_id)
+
+ # Check export permission
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
+ prof = cur.fetchone()
+ if not prof or not prof['export_enabled']:
+ raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
+
+ # Build CSV
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ # Header
+ writer.writerow(["Typ", "Datum", "Wert", "Details"])
+
+ # Weight
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT date, weight, note FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ writer.writerow(["Gewicht", r['date'], f"{float(r['weight'])}kg", r['note'] or ""])
+
+ # Circumferences
+ cur.execute("SELECT date, c_waist, c_belly, c_hip FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ details = f"Taille:{float(r['c_waist'])}cm Bauch:{float(r['c_belly'])}cm Hüfte:{float(r['c_hip'])}cm"
+ writer.writerow(["Umfänge", r['date'], "", details])
+
+ # Caliper
+ cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ writer.writerow(["Caliper", r['date'], f"{float(r['body_fat_pct'])}%", f"Magermasse:{float(r['lean_mass'])}kg"])
+
+ # Nutrition
+ cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
+
+ # Activity
+ cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
+
+ output.seek(0)
+ return StreamingResponse(
+ iter([output.getvalue()]),
+ media_type="text/csv",
+ headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"}
+ )
+
+@app.get("/api/export/json")
+def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Export all data as JSON."""
+ pid = get_pid(x_profile_id)
+
+ # Check export permission
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
+ prof = cur.fetchone()
+ if not prof or not prof['export_enabled']:
+ raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
+
+ # Collect all data
+ data = {}
+ with get_db() as conn:
+ cur = get_cursor(conn)
+
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ data['profile'] = r2d(cur.fetchone())
+
+ cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['weight'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['circumferences'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['caliper'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['nutrition'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['activity'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
+ data['insights'] = [r2d(r) for r in cur.fetchall()]
+
+ def decimal_handler(obj):
+ if isinstance(obj, Decimal):
+ return float(obj)
+ return str(obj)
+
+ json_str = json.dumps(data, indent=2, default=decimal_handler)
+ return Response(
+ content=json_str,
+ media_type="application/json",
+ headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.json"}
+ )
+
+@app.get("/api/export/zip")
+def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Export all data as ZIP (CSV + JSON + photos) per specification."""
+ pid = get_pid(x_profile_id)
+
+ # Check export permission & get profile
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ prof = r2d(cur.fetchone())
+ if not prof or not prof.get('export_enabled'):
+ raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
+
+ # Helper: CSV writer with UTF-8 BOM + semicolon
+ def write_csv(zf, filename, rows, columns):
+ if not rows:
+ return
+ output = io.StringIO()
+ writer = csv.writer(output, delimiter=';')
+ writer.writerow(columns)
+ for r in rows:
+ writer.writerow([
+ '' if r.get(col) is None else
+ (float(r[col]) if isinstance(r.get(col), Decimal) else r[col])
+ for col in columns
+ ])
+ # UTF-8 with BOM for Excel
+ csv_bytes = '\ufeff'.encode('utf-8') + output.getvalue().encode('utf-8')
+ zf.writestr(f"data/{filename}", csv_bytes)
+
+ # Create ZIP
+ zip_buffer = io.BytesIO()
+ export_date = datetime.now().strftime('%Y-%m-%d')
+ profile_name = prof.get('name', 'export')
+
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
+ with get_db() as conn:
+ cur = get_cursor(conn)
+
+ # 1. README.txt
+ readme = f"""Mitai Jinkendo – Datenexport
+Version: 2
+Exportiert am: {export_date}
+Profil: {profile_name}
+
+Inhalt:
+- profile.json: Profildaten und Einstellungen
+- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8)
+- insights/: KI-Auswertungen (JSON)
+- photos/: Progress-Fotos (JPEG)
+
+Import:
+Dieser Export kann in Mitai Jinkendo unter
+Einstellungen → Import → "Mitai Backup importieren"
+wieder eingespielt werden.
+
+Format-Version 2 (ab v9b):
+Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
+Trennzeichen: Semikolon (;)
+Datumsformat: YYYY-MM-DD
+"""
+ zf.writestr("README.txt", readme.encode('utf-8'))
+
+ # 2. profile.json (ohne Passwort-Hash)
+ cur.execute("SELECT COUNT(*) as c FROM weight_log WHERE profile_id=%s", (pid,))
+ w_count = cur.fetchone()['c']
+ cur.execute("SELECT COUNT(*) as c FROM nutrition_log WHERE profile_id=%s", (pid,))
+ n_count = cur.fetchone()['c']
+ cur.execute("SELECT COUNT(*) as c FROM activity_log WHERE profile_id=%s", (pid,))
+ a_count = cur.fetchone()['c']
+ cur.execute("SELECT COUNT(*) as c FROM photos WHERE profile_id=%s", (pid,))
+ p_count = cur.fetchone()['c']
+
+ profile_data = {
+ "export_version": "2",
+ "export_date": export_date,
+ "app": "Mitai Jinkendo",
+ "profile": {
+ "name": prof.get('name'),
+ "email": prof.get('email'),
+ "sex": prof.get('sex'),
+ "height": float(prof['height']) if prof.get('height') else None,
+ "birth_year": prof['dob'].year if prof.get('dob') else None,
+ "goal_weight": float(prof['goal_weight']) if prof.get('goal_weight') else None,
+ "goal_bf_pct": float(prof['goal_bf_pct']) if prof.get('goal_bf_pct') else None,
+ "avatar_color": prof.get('avatar_color'),
+ "auth_type": prof.get('auth_type'),
+ "session_days": prof.get('session_days'),
+ "ai_enabled": prof.get('ai_enabled'),
+ "tier": prof.get('tier')
+ },
+ "stats": {
+ "weight_entries": w_count,
+ "nutrition_entries": n_count,
+ "activity_entries": a_count,
+ "photos": p_count
+ }
+ }
+ zf.writestr("profile.json", json.dumps(profile_data, indent=2, ensure_ascii=False).encode('utf-8'))
+
+ # 3. data/weight.csv
+ cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
+ write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()],
+ ['id','date','weight','note','source','created'])
+
+ # 4. data/circumferences.csv
+ cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ # Rename columns to match spec
+ for r in rows:
+ r['waist'] = r.pop('c_waist', None)
+ r['hip'] = r.pop('c_hip', None)
+ r['chest'] = r.pop('c_chest', None)
+ r['neck'] = r.pop('c_neck', None)
+ r['upper_arm'] = r.pop('c_arm', None)
+ r['thigh'] = r.pop('c_thigh', None)
+ r['calf'] = r.pop('c_calf', None)
+ r['forearm'] = None # not tracked
+ r['note'] = r.pop('notes', None)
+ write_csv(zf, "circumferences.csv", rows,
+ ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created'])
+
+ # 5. data/caliper.csv
+ cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ for r in rows:
+ r['chest'] = r.pop('sf_chest', None)
+ r['abdomen'] = r.pop('sf_abdomen', None)
+ r['thigh'] = r.pop('sf_thigh', None)
+ r['tricep'] = r.pop('sf_triceps', None)
+ r['subscapular'] = r.pop('sf_subscap', None)
+ r['suprailiac'] = r.pop('sf_suprailiac', None)
+ r['midaxillary'] = r.pop('sf_axilla', None)
+ r['method'] = r.pop('sf_method', None)
+ r['bf_percent'] = r.pop('body_fat_pct', None)
+ r['note'] = r.pop('notes', None)
+ write_csv(zf, "caliper.csv", rows,
+ ['id','date','chest','abdomen','thigh','tricep','subscapular','suprailiac','midaxillary','method','bf_percent','note','created'])
+
+ # 6. data/nutrition.csv
+ cur.execute("SELECT id, date, kcal, protein_g, fat_g, carbs_g, source, created FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ for r in rows:
+ r['meal_name'] = '' # not tracked per meal
+ r['protein'] = r.pop('protein_g', None)
+ r['fat'] = r.pop('fat_g', None)
+ r['carbs'] = r.pop('carbs_g', None)
+ r['fiber'] = None # not tracked
+ r['note'] = ''
+ write_csv(zf, "nutrition.csv", rows,
+ ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
+
+ # 7. data/activity.csv
+ cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ for r in rows:
+ r['name'] = r['activity_type']
+ r['type'] = r.pop('activity_type', None)
+ r['kcal'] = r.pop('kcal_active', None)
+ r['heart_rate_avg'] = r.pop('hr_avg', None)
+ r['heart_rate_max'] = r.pop('hr_max', None)
+ r['note'] = r.pop('notes', None)
+ write_csv(zf, "activity.csv", rows,
+ ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created'])
+
+ # 8. insights/ai_insights.json
+ cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
+ insights = []
+ for r in cur.fetchall():
+ rd = r2d(r)
+ insights.append({
+ "id": rd['id'],
+ "scope": rd['scope'],
+ "created": rd['created'].isoformat() if hasattr(rd['created'], 'isoformat') else str(rd['created']),
+ "result": rd['content']
+ })
+ if insights:
+ zf.writestr("insights/ai_insights.json", json.dumps(insights, indent=2, ensure_ascii=False).encode('utf-8'))
+
+ # 9. photos/
+ cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
+ photos = [r2d(r) for r in cur.fetchall()]
+ for i, photo in enumerate(photos):
+ photo_path = Path(PHOTOS_DIR) / photo['path']
+ if photo_path.exists():
+ filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}"
+ zf.write(photo_path, f"photos/{filename}")
+
+ zip_buffer.seek(0)
+ filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
+ return StreamingResponse(
+ iter([zip_buffer.getvalue()]),
+ media_type="application/zip",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
+
+
+# ── Import ZIP ──────────────────────────────────────────────────
+@app.post("/api/import/zip")
+async def import_zip(
+ file: UploadFile = File(...),
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth)
+):
+ """
+ Import data from ZIP export file.
+
+ - Validates export format
+ - Imports missing entries only (ON CONFLICT DO NOTHING)
+ - Imports photos
+ - Returns import summary
+ - Full rollback on error
+ """
+ pid = get_pid(x_profile_id)
+
+ # Read uploaded file
+ content = await file.read()
+ zip_buffer = io.BytesIO(content)
+
+ try:
+ with zipfile.ZipFile(zip_buffer, 'r') as zf:
+ # 1. Validate profile.json
+ if 'profile.json' not in zf.namelist():
+ raise HTTPException(400, "Ungültiger Export: profile.json fehlt")
+
+ profile_data = json.loads(zf.read('profile.json').decode('utf-8'))
+ export_version = profile_data.get('export_version', '1')
+
+ # Stats tracker
+ stats = {
+ 'weight': 0,
+ 'circumferences': 0,
+ 'caliper': 0,
+ 'nutrition': 0,
+ 'activity': 0,
+ 'photos': 0,
+ 'insights': 0
+ }
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+
+ try:
+ # 2. Import weight.csv
+ if 'data/weight.csv' in zf.namelist():
+ csv_data = zf.read('data/weight.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO weight_log (profile_id, date, weight, note, source, created)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ ON CONFLICT (profile_id, date) DO NOTHING
+ """, (
+ pid,
+ row['date'],
+ float(row['weight']) if row['weight'] else None,
+ row.get('note', ''),
+ row.get('source', 'import'),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['weight'] += 1
+
+ # 3. Import circumferences.csv
+ if 'data/circumferences.csv' in zf.namelist():
+ csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ # Map CSV columns to DB columns
+ cur.execute("""
+ INSERT INTO circumference_log (
+ profile_id, date, c_waist, c_hip, c_chest, c_neck,
+ c_arm, c_thigh, c_calf, notes, created
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ ON CONFLICT (profile_id, date) DO NOTHING
+ """, (
+ pid,
+ row['date'],
+ float(row['waist']) if row.get('waist') else None,
+ float(row['hip']) if row.get('hip') else None,
+ float(row['chest']) if row.get('chest') else None,
+ float(row['neck']) if row.get('neck') else None,
+ float(row['upper_arm']) if row.get('upper_arm') else None,
+ float(row['thigh']) if row.get('thigh') else None,
+ float(row['calf']) if row.get('calf') else None,
+ row.get('note', ''),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['circumferences'] += 1
+
+ # 4. Import caliper.csv
+ if 'data/caliper.csv' in zf.namelist():
+ csv_data = zf.read('data/caliper.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO caliper_log (
+ profile_id, date, sf_chest, sf_abdomen, sf_thigh,
+ sf_triceps, sf_subscap, sf_suprailiac, sf_axilla,
+ sf_method, body_fat_pct, notes, created
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ ON CONFLICT (profile_id, date) DO NOTHING
+ """, (
+ pid,
+ row['date'],
+ float(row['chest']) if row.get('chest') else None,
+ float(row['abdomen']) if row.get('abdomen') else None,
+ float(row['thigh']) if row.get('thigh') else None,
+ float(row['tricep']) if row.get('tricep') else None,
+ float(row['subscapular']) if row.get('subscapular') else None,
+ float(row['suprailiac']) if row.get('suprailiac') else None,
+ float(row['midaxillary']) if row.get('midaxillary') else None,
+ row.get('method', 'jackson3'),
+ float(row['bf_percent']) if row.get('bf_percent') else None,
+ row.get('note', ''),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['caliper'] += 1
+
+ # 5. Import nutrition.csv
+ if 'data/nutrition.csv' in zf.namelist():
+ csv_data = zf.read('data/nutrition.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO nutrition_log (
+ profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ ON CONFLICT (profile_id, date) DO NOTHING
+ """, (
+ pid,
+ row['date'],
+ float(row['kcal']) if row.get('kcal') else None,
+ float(row['protein']) if row.get('protein') else None,
+ float(row['fat']) if row.get('fat') else None,
+ float(row['carbs']) if row.get('carbs') else None,
+ row.get('source', 'import'),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['nutrition'] += 1
+
+ # 6. Import activity.csv
+ if 'data/activity.csv' in zf.namelist():
+ csv_data = zf.read('data/activity.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO activity_log (
+ profile_id, date, activity_type, duration_min,
+ kcal_active, hr_avg, hr_max, distance_km, notes, source, created
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ """, (
+ pid,
+ row['date'],
+ row.get('type', 'Training'),
+ float(row['duration_min']) if row.get('duration_min') else None,
+ float(row['kcal']) if row.get('kcal') else None,
+ float(row['heart_rate_avg']) if row.get('heart_rate_avg') else None,
+ float(row['heart_rate_max']) if row.get('heart_rate_max') else None,
+ float(row['distance_km']) if row.get('distance_km') else None,
+ row.get('note', ''),
+ row.get('source', 'import'),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['activity'] += 1
+
+ # 7. Import ai_insights.json
+ if 'insights/ai_insights.json' in zf.namelist():
+ insights_data = json.loads(zf.read('insights/ai_insights.json').decode('utf-8'))
+ for insight in insights_data:
+ cur.execute("""
+ INSERT INTO ai_insights (profile_id, scope, content, created)
+ VALUES (%s, %s, %s, %s)
+ """, (
+ pid,
+ insight['scope'],
+ insight['result'],
+ insight.get('created', datetime.now())
+ ))
+ stats['insights'] += 1
+
+ # 8. Import photos
+ photo_files = [f for f in zf.namelist() if f.startswith('photos/') and not f.endswith('/')]
+ for photo_file in photo_files:
+ # Extract date from filename (format: YYYY-MM-DD_N.jpg)
+ filename = Path(photo_file).name
+ parts = filename.split('_')
+ photo_date = parts[0] if len(parts) > 0 else datetime.now().strftime('%Y-%m-%d')
+
+ # Generate new ID and path
+ photo_id = str(uuid.uuid4())
+ ext = Path(filename).suffix
+ new_filename = f"{photo_id}{ext}"
+ target_path = PHOTOS_DIR / new_filename
+
+ # Check if photo already exists for this date
+ cur.execute("""
+ SELECT id FROM photos
+ WHERE profile_id = %s AND date = %s
+ """, (pid, photo_date))
+
+ if cur.fetchone() is None:
+ # Write photo file
+ with open(target_path, 'wb') as f:
+ f.write(zf.read(photo_file))
+
+ # Insert DB record
+ cur.execute("""
+ INSERT INTO photos (id, profile_id, date, path, created)
+ VALUES (%s, %s, %s, %s, %s)
+ """, (photo_id, pid, photo_date, new_filename, datetime.now()))
+ stats['photos'] += 1
+
+ # Commit transaction
+ conn.commit()
+
+ except Exception as e:
+ # Rollback on any error
+ conn.rollback()
+ raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
+
+ return {
+ "ok": True,
+ "message": "Import erfolgreich",
+ "stats": stats,
+ "total": sum(stats.values())
+ }
+
+ except zipfile.BadZipFile:
+ raise HTTPException(400, "Ungültige ZIP-Datei")
+ except Exception as e:
+ raise HTTPException(500, f"Import-Fehler: {str(e)}")
diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/routers/activity.py b/backend/routers/activity.py
new file mode 100644
index 0000000..8ff768d
--- /dev/null
+++ b/backend/routers/activity.py
@@ -0,0 +1,137 @@
+"""
+Activity Tracking Endpoints for Mitai Jinkendo
+
+Handles workout/activity logging, statistics, and Apple Health CSV import.
+"""
+import csv
+import io
+import uuid
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth
+from models import ActivityEntry
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api/activity", tags=["activity"])
+
+
+@router.get("")
+def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get activity entries for current profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC, start_time DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("")
+def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Create new activity entry."""
+ pid = get_pid(x_profile_id)
+ eid = str(uuid.uuid4())
+ d = e.model_dump()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("""INSERT INTO activity_log
+ (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
+ hr_avg,hr_max,distance_km,rpe,source,notes,created)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
+ (eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
+ d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
+ d['rpe'],d['source'],d['notes']))
+ return {"id":eid,"date":e.date}
+
+
+@router.put("/{eid}")
+def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Update existing activity entry."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ d = e.model_dump()
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
+ list(d.values())+[eid,pid])
+ return {"id":eid}
+
+
+@router.delete("/{eid}")
+def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Delete activity entry."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM activity_log WHERE id=%s AND profile_id=%s", (eid,pid))
+ return {"ok":True}
+
+
+@router.get("/stats")
+def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get activity statistics (last 30 entries)."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
+ total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
+ total_min=sum(float(r.get('duration_min') or 0) for r in rows)
+ by_type={}
+ for r in rows:
+ t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
+ by_type[t]['count']+=1
+ by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
+ by_type[t]['min']+=float(r.get('duration_min') or 0)
+ return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
+
+
+@router.post("/import-csv")
+async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Import Apple Health workout CSV."""
+ pid = get_pid(x_profile_id)
+ raw = await file.read()
+ try: text = raw.decode('utf-8')
+ except: text = raw.decode('latin-1')
+ if text.startswith('\ufeff'): text = text[1:]
+ if not text.strip(): raise HTTPException(400,"Leere Datei")
+ reader = csv.DictReader(io.StringIO(text))
+ inserted = skipped = 0
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ for row in reader:
+ wtype = row.get('Workout Type','').strip()
+ start = row.get('Start','').strip()
+ if not wtype or not start: continue
+ try: date = start[:10]
+ except: continue
+ dur = row.get('Duration','').strip()
+ duration_min = None
+ if dur:
+ try:
+ p = dur.split(':')
+ duration_min = round(int(p[0])*60+int(p[1])+int(p[2])/60,1)
+ except: pass
+ def kj(v):
+ try: return round(float(v)/4.184) if v else None
+ except: return None
+ def tf(v):
+ try: return round(float(v),1) if v else None
+ except: return None
+ try:
+ cur.execute("""INSERT INTO activity_log
+ (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
+ hr_avg,hr_max,distance_km,source,created)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""",
+ (str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
+ kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
+ tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
+ tf(row.get('Max. Herzfrequenz (count/min)','')),
+ tf(row.get('Distanz (km)',''))))
+ inserted+=1
+ except: skipped+=1
+ return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
diff --git a/backend/routers/admin.py b/backend/routers/admin.py
new file mode 100644
index 0000000..e7d5a62
--- /dev/null
+++ b/backend/routers/admin.py
@@ -0,0 +1,157 @@
+"""
+Admin Management Endpoints for Mitai Jinkendo
+
+Handles user management, permissions, and email testing (admin-only).
+"""
+import os
+import smtplib
+from email.mime.text import MIMEText
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_admin, hash_pin
+from models import AdminProfileUpdate
+
+router = APIRouter(prefix="/api/admin", tags=["admin"])
+
+
+@router.get("/profiles")
+def admin_list_profiles(session: dict=Depends(require_admin)):
+ """Admin: List all profiles with stats."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles ORDER BY created")
+ profs = [r2d(r) for r in cur.fetchall()]
+
+ for p in profs:
+ pid = p['id']
+ cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s", (pid,))
+ p['weight_count'] = cur.fetchone()['count']
+ cur.execute("SELECT COUNT(*) as count FROM ai_insights WHERE profile_id=%s", (pid,))
+ p['ai_insights_count'] = cur.fetchone()['count']
+
+ today = datetime.now().date().isoformat()
+ cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
+ usage = cur.fetchone()
+ p['ai_usage_today'] = usage['call_count'] if usage else 0
+
+ return profs
+
+
+@router.put("/profiles/{pid}")
+def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depends(require_admin)):
+ """Admin: Update profile settings."""
+ with get_db() as conn:
+ updates = {k:v for k,v in data.model_dump().items() if v is not None}
+ if not updates:
+ return {"ok": True}
+
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in updates)} WHERE id=%s",
+ list(updates.values()) + [pid])
+
+ return {"ok": True}
+
+
+@router.put("/profiles/{pid}/permissions")
+def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)):
+ """Admin: Set profile permissions."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ updates = []
+ values = []
+ if 'ai_enabled' in data:
+ updates.append('ai_enabled=%s')
+ values.append(data['ai_enabled'])
+ if 'ai_limit_day' in data:
+ updates.append('ai_limit_day=%s')
+ values.append(data['ai_limit_day'])
+ if 'export_enabled' in data:
+ updates.append('export_enabled=%s')
+ values.append(data['export_enabled'])
+ if 'role' in data:
+ updates.append('role=%s')
+ values.append(data['role'])
+
+ if updates:
+ cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid])
+
+ return {"ok": True}
+
+
+@router.put("/profiles/{pid}/email")
+def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)):
+ """Admin: Set profile email."""
+ email = data.get('email', '').strip().lower()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid))
+
+ return {"ok": True}
+
+
+@router.put("/profiles/{pid}/pin")
+def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)):
+ """Admin: Set profile PIN/password."""
+ new_pin = data.get('pin', '')
+ if len(new_pin) < 4:
+ raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
+
+ new_hash = hash_pin(new_pin)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
+
+ return {"ok": True}
+
+
+@router.get("/email/status")
+def admin_email_status(session: dict=Depends(require_admin)):
+ """Admin: Check email configuration status."""
+ smtp_host = os.getenv("SMTP_HOST")
+ smtp_user = os.getenv("SMTP_USER")
+ smtp_pass = os.getenv("SMTP_PASS")
+ app_url = os.getenv("APP_URL", "http://localhost:3002")
+
+ configured = bool(smtp_host and smtp_user and smtp_pass)
+
+ return {
+ "configured": configured,
+ "smtp_host": smtp_host or "",
+ "smtp_user": smtp_user or "",
+ "app_url": app_url
+ }
+
+
+@router.post("/email/test")
+def admin_test_email(data: dict, session: dict=Depends(require_admin)):
+ """Admin: Send test email."""
+ email = data.get('to', '')
+ if not email:
+ raise HTTPException(400, "E-Mail-Adresse fehlt")
+
+ try:
+ smtp_host = os.getenv("SMTP_HOST")
+ smtp_port = int(os.getenv("SMTP_PORT", 587))
+ smtp_user = os.getenv("SMTP_USER")
+ smtp_pass = os.getenv("SMTP_PASS")
+ smtp_from = os.getenv("SMTP_FROM")
+
+ if not smtp_host or not smtp_user or not smtp_pass:
+ raise HTTPException(500, "SMTP nicht konfiguriert")
+
+ msg = MIMEText("Dies ist eine Test-E-Mail von Mitai Jinkendo.")
+ msg['Subject'] = "Test-E-Mail"
+ msg['From'] = smtp_from
+ msg['To'] = email
+
+ with smtplib.SMTP(smtp_host, smtp_port) as server:
+ server.starttls()
+ server.login(smtp_user, smtp_pass)
+ server.send_message(msg)
+
+ return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"}
+ except Exception as e:
+ raise HTTPException(500, f"Fehler beim Senden: {str(e)}")
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
new file mode 100644
index 0000000..25f88f4
--- /dev/null
+++ b/backend/routers/auth.py
@@ -0,0 +1,176 @@
+"""
+Authentication Endpoints for Mitai Jinkendo
+
+Handles login, logout, password reset, and profile authentication.
+"""
+import os
+import secrets
+import smtplib
+from typing import Optional
+from datetime import datetime, timedelta
+from email.mime.text import MIMEText
+
+from fastapi import APIRouter, HTTPException, Header, Depends
+from starlette.requests import Request
+from slowapi import Limiter
+from slowapi.util import get_remote_address
+
+from db import get_db, get_cursor
+from auth import hash_pin, verify_pin, make_token, require_auth
+from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+limiter = Limiter(key_func=get_remote_address)
+
+
+@router.post("/login")
+@limiter.limit("5/minute")
+async def login(req: LoginRequest, request: Request):
+ """Login with email + password."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),))
+ prof = cur.fetchone()
+ if not prof:
+ raise HTTPException(401, "Ungültige Zugangsdaten")
+
+ # Verify password
+ if not verify_pin(req.password, prof['pin_hash']):
+ raise HTTPException(401, "Ungültige Zugangsdaten")
+
+ # Auto-upgrade from SHA256 to bcrypt
+ if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'):
+ new_hash = hash_pin(req.password)
+ cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id']))
+
+ # Create session
+ token = make_token()
+ session_days = prof.get('session_days', 30)
+ expires = datetime.now() + timedelta(days=session_days)
+ cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
+ (token, prof['id'], expires.isoformat()))
+
+ return {
+ "token": token,
+ "profile_id": prof['id'],
+ "name": prof['name'],
+ "role": prof['role'],
+ "expires_at": expires.isoformat()
+ }
+
+
+@router.post("/logout")
+def logout(x_auth_token: Optional[str]=Header(default=None)):
+ """Logout (delete session)."""
+ if x_auth_token:
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,))
+ return {"ok": True}
+
+
+@router.get("/me")
+def get_me(session: dict=Depends(require_auth)):
+ """Get current user info."""
+ pid = session['profile_id']
+ # Import here to avoid circular dependency
+ from routers.profiles import get_profile
+ return get_profile(pid, session)
+
+
+@router.get("/status")
+def auth_status():
+ """Health check endpoint."""
+ return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"}
+
+
+@router.put("/pin")
+def change_pin(req: dict, session: dict=Depends(require_auth)):
+ """Change PIN/password for current user."""
+ pid = session['profile_id']
+ new_pin = req.get('pin', '')
+ if len(new_pin) < 4:
+ raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
+
+ new_hash = hash_pin(new_pin)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
+
+ return {"ok": True}
+
+
+@router.post("/forgot-password")
+@limiter.limit("3/minute")
+async def password_reset_request(req: PasswordResetRequest, request: Request):
+ """Request password reset email."""
+ email = req.email.lower().strip()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
+ prof = cur.fetchone()
+ if not prof:
+ # Don't reveal if email exists
+ return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
+
+ # Generate reset token
+ token = secrets.token_urlsafe(32)
+ expires = datetime.now() + timedelta(hours=1)
+
+ # Store in sessions table (reuse mechanism)
+ cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
+ (f"reset_{token}", prof['id'], expires.isoformat()))
+
+ # Send email
+ try:
+ smtp_host = os.getenv("SMTP_HOST")
+ smtp_port = int(os.getenv("SMTP_PORT", 587))
+ smtp_user = os.getenv("SMTP_USER")
+ smtp_pass = os.getenv("SMTP_PASS")
+ smtp_from = os.getenv("SMTP_FROM")
+ app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
+
+ if smtp_host and smtp_user and smtp_pass:
+ msg = MIMEText(f"""Hallo {prof['name']},
+
+Du hast einen Passwort-Reset angefordert.
+
+Reset-Link: {app_url}/reset-password?token={token}
+
+Der Link ist 1 Stunde gültig.
+
+Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
+
+Dein Mitai Jinkendo Team
+""")
+ msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo"
+ msg['From'] = smtp_from
+ msg['To'] = email
+
+ with smtplib.SMTP(smtp_host, smtp_port) as server:
+ server.starttls()
+ server.login(smtp_user, smtp_pass)
+ server.send_message(msg)
+ except Exception as e:
+ print(f"Email error: {e}")
+
+ return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
+
+
+@router.post("/reset-password")
+def password_reset_confirm(req: PasswordResetConfirm):
+ """Confirm password reset with token."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP",
+ (f"reset_{req.token}",))
+ sess = cur.fetchone()
+ if not sess:
+ raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
+
+ pid = sess['profile_id']
+ new_hash = hash_pin(req.new_password)
+ cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
+ cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
+
+ return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
diff --git a/backend/routers/caliper.py b/backend/routers/caliper.py
new file mode 100644
index 0000000..b217a4c
--- /dev/null
+++ b/backend/routers/caliper.py
@@ -0,0 +1,75 @@
+"""
+Caliper/Skinfold Tracking Endpoints for Mitai Jinkendo
+
+Handles body fat measurements via skinfold caliper (4 methods supported).
+"""
+import uuid
+from typing import Optional
+
+from fastapi import APIRouter, Header, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth
+from models import CaliperEntry
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api/caliper", tags=["caliper"])
+
+
+@router.get("")
+def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get caliper entries for current profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("")
+def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Create or update caliper entry (upsert by date)."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
+ ex = cur.fetchone()
+ d = e.model_dump()
+ if ex:
+ eid = ex['id']
+ sets = ', '.join(f"{k}=%s" for k in d if k!='date')
+ cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
+ [v for k,v in d.items() if k!='date']+[eid])
+ else:
+ eid = str(uuid.uuid4())
+ cur.execute("""INSERT INTO caliper_log
+ (id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
+ sf_abdomen,sf_thigh,sf_calf_med,sf_lowerback,sf_biceps,body_fat_pct,lean_mass,fat_mass,notes,created)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
+ (eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'],
+ d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'],
+ d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes']))
+ return {"id":eid,"date":e.date}
+
+
+@router.put("/{eid}")
+def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Update existing caliper entry."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ d = e.model_dump()
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE caliper_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
+ list(d.values())+[eid,pid])
+ return {"id":eid}
+
+
+@router.delete("/{eid}")
+def delete_caliper(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Delete caliper entry."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM caliper_log WHERE id=%s AND profile_id=%s", (eid,pid))
+ return {"ok":True}
diff --git a/backend/routers/circumference.py b/backend/routers/circumference.py
new file mode 100644
index 0000000..feba22c
--- /dev/null
+++ b/backend/routers/circumference.py
@@ -0,0 +1,73 @@
+"""
+Circumference Tracking Endpoints for Mitai Jinkendo
+
+Handles body circumference measurements (8 measurement points).
+"""
+import uuid
+from typing import Optional
+
+from fastapi import APIRouter, Header, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth
+from models import CircumferenceEntry
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
+
+
+@router.get("")
+def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get circumference entries for current profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("")
+def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Create or update circumference entry (upsert by date)."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
+ ex = cur.fetchone()
+ d = e.model_dump()
+ if ex:
+ eid = ex['id']
+ sets = ', '.join(f"{k}=%s" for k in d if k!='date')
+ cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
+ [v for k,v in d.items() if k!='date']+[eid])
+ else:
+ eid = str(uuid.uuid4())
+ cur.execute("""INSERT INTO circumference_log
+ (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
+ (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
+ d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
+ return {"id":eid,"date":e.date}
+
+
+@router.put("/{eid}")
+def update_circ(eid: str, e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Update existing circumference entry."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ d = e.model_dump()
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE circumference_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
+ list(d.values())+[eid,pid])
+ return {"id":eid}
+
+
+@router.delete("/{eid}")
+def delete_circ(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Delete circumference entry."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM circumference_log WHERE id=%s AND profile_id=%s", (eid,pid))
+ return {"ok":True}
diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py
new file mode 100644
index 0000000..02109ea
--- /dev/null
+++ b/backend/routers/exportdata.py
@@ -0,0 +1,304 @@
+"""
+Data Export Endpoints for Mitai Jinkendo
+
+Handles CSV, JSON, and ZIP exports with photos.
+"""
+import os
+import csv
+import io
+import json
+import zipfile
+from pathlib import Path
+from typing import Optional
+from datetime import datetime
+from decimal import Decimal
+
+from fastapi import APIRouter, HTTPException, Header, Depends
+from fastapi.responses import StreamingResponse, Response
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api/export", tags=["export"])
+
+PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
+
+
+@router.get("/csv")
+def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Export all data as CSV."""
+ pid = get_pid(x_profile_id)
+
+ # Check export permission
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
+ prof = cur.fetchone()
+ if not prof or not prof['export_enabled']:
+ raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
+
+ # Build CSV
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ # Header
+ writer.writerow(["Typ", "Datum", "Wert", "Details"])
+
+ # Weight
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT date, weight, note FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ writer.writerow(["Gewicht", r['date'], f"{float(r['weight'])}kg", r['note'] or ""])
+
+ # Circumferences
+ cur.execute("SELECT date, c_waist, c_belly, c_hip FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ details = f"Taille:{float(r['c_waist'])}cm Bauch:{float(r['c_belly'])}cm Hüfte:{float(r['c_hip'])}cm"
+ writer.writerow(["Umfänge", r['date'], "", details])
+
+ # Caliper
+ cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ writer.writerow(["Caliper", r['date'], f"{float(r['body_fat_pct'])}%", f"Magermasse:{float(r['lean_mass'])}kg"])
+
+ # Nutrition
+ cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
+
+ # Activity
+ cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
+ for r in cur.fetchall():
+ writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
+
+ output.seek(0)
+ return StreamingResponse(
+ iter([output.getvalue()]),
+ media_type="text/csv",
+ headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"}
+ )
+
+
+@router.get("/json")
+def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Export all data as JSON."""
+ pid = get_pid(x_profile_id)
+
+ # Check export permission
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
+ prof = cur.fetchone()
+ if not prof or not prof['export_enabled']:
+ raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
+
+ # Collect all data
+ data = {}
+ with get_db() as conn:
+ cur = get_cursor(conn)
+
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ data['profile'] = r2d(cur.fetchone())
+
+ cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['weight'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['circumferences'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['caliper'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['nutrition'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
+ data['activity'] = [r2d(r) for r in cur.fetchall()]
+
+ cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
+ data['insights'] = [r2d(r) for r in cur.fetchall()]
+
+ def decimal_handler(obj):
+ if isinstance(obj, Decimal):
+ return float(obj)
+ return str(obj)
+
+ json_str = json.dumps(data, indent=2, default=decimal_handler)
+ return Response(
+ content=json_str,
+ media_type="application/json",
+ headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.json"}
+ )
+
+
+@router.get("/zip")
+def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Export all data as ZIP (CSV + JSON + photos) per specification."""
+ pid = get_pid(x_profile_id)
+
+ # Check export permission & get profile
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ prof = r2d(cur.fetchone())
+ if not prof or not prof.get('export_enabled'):
+ raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
+
+ # Helper: CSV writer with UTF-8 BOM + semicolon
+ def write_csv(zf, filename, rows, columns):
+ if not rows:
+ return
+ output = io.StringIO()
+ writer = csv.writer(output, delimiter=';')
+ writer.writerow(columns)
+ for r in rows:
+ writer.writerow([
+ '' if r.get(col) is None else
+ (float(r[col]) if isinstance(r.get(col), Decimal) else r[col])
+ for col in columns
+ ])
+ # UTF-8 with BOM for Excel
+ csv_bytes = '\ufeff'.encode('utf-8') + output.getvalue().encode('utf-8')
+ zf.writestr(f"data/{filename}", csv_bytes)
+
+ # Create ZIP
+ zip_buffer = io.BytesIO()
+ export_date = datetime.now().strftime('%Y-%m-%d')
+ profile_name = prof.get('name', 'export')
+
+ with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
+ with get_db() as conn:
+ cur = get_cursor(conn)
+
+ # 1. README.txt
+ readme = f"""Mitai Jinkendo – Datenexport
+Version: 2
+Exportiert am: {export_date}
+Profil: {profile_name}
+
+Inhalt:
+- profile.json: Profildaten und Einstellungen
+- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8)
+- insights/: KI-Auswertungen (JSON)
+- photos/: Progress-Fotos (JPEG)
+
+Import:
+Dieser Export kann in Mitai Jinkendo unter
+Einstellungen → Import → "Mitai Backup importieren"
+wieder eingespielt werden.
+
+Format-Version 2 (ab v9b):
+Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
+Trennzeichen: Semikolon (;)
+Datumsformat: YYYY-MM-DD
+"""
+ zf.writestr("README.txt", readme.encode('utf-8'))
+
+ # 2. profile.json (ohne Passwort-Hash)
+ cur.execute("SELECT COUNT(*) as c FROM weight_log WHERE profile_id=%s", (pid,))
+ w_count = cur.fetchone()['c']
+ cur.execute("SELECT COUNT(*) as c FROM nutrition_log WHERE profile_id=%s", (pid,))
+ n_count = cur.fetchone()['c']
+ cur.execute("SELECT COUNT(*) as c FROM activity_log WHERE profile_id=%s", (pid,))
+ a_count = cur.fetchone()['c']
+ cur.execute("SELECT COUNT(*) as c FROM photos WHERE profile_id=%s", (pid,))
+ p_count = cur.fetchone()['c']
+
+ profile_data = {
+ "export_version": "2",
+ "export_date": export_date,
+ "app": "Mitai Jinkendo",
+ "profile": {
+ "name": prof.get('name'),
+ "email": prof.get('email'),
+ "sex": prof.get('sex'),
+ "height": float(prof['height']) if prof.get('height') else None,
+ "birth_year": prof['dob'].year if prof.get('dob') else None,
+ "goal_weight": float(prof['goal_weight']) if prof.get('goal_weight') else None,
+ "goal_bf_pct": float(prof['goal_bf_pct']) if prof.get('goal_bf_pct') else None,
+ "avatar_color": prof.get('avatar_color'),
+ "auth_type": prof.get('auth_type'),
+ "session_days": prof.get('session_days'),
+ "ai_enabled": prof.get('ai_enabled'),
+ "tier": prof.get('tier')
+ },
+ "stats": {
+ "weight_entries": w_count,
+ "nutrition_entries": n_count,
+ "activity_entries": a_count,
+ "photos": p_count
+ }
+ }
+ zf.writestr("profile.json", json.dumps(profile_data, indent=2, ensure_ascii=False).encode('utf-8'))
+
+ # 3-7. CSV exports (weight, circumferences, caliper, nutrition, activity)
+ cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
+ write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], ['id','date','weight','note','source','created'])
+
+ cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ for r in rows:
+ r['waist'] = r.pop('c_waist', None); r['hip'] = r.pop('c_hip', None)
+ r['chest'] = r.pop('c_chest', None); r['neck'] = r.pop('c_neck', None)
+ r['upper_arm'] = r.pop('c_arm', None); r['thigh'] = r.pop('c_thigh', None)
+ r['calf'] = r.pop('c_calf', None); r['forearm'] = None; r['note'] = r.pop('notes', None)
+ write_csv(zf, "circumferences.csv", rows, ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created'])
+
+ cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ for r in rows:
+ r['chest'] = r.pop('sf_chest', None); r['abdomen'] = r.pop('sf_abdomen', None)
+ r['thigh'] = r.pop('sf_thigh', None); r['tricep'] = r.pop('sf_triceps', None)
+ r['subscapular'] = r.pop('sf_subscap', None); r['suprailiac'] = r.pop('sf_suprailiac', None)
+ r['midaxillary'] = r.pop('sf_axilla', None); r['method'] = r.pop('sf_method', None)
+ r['bf_percent'] = r.pop('body_fat_pct', None); r['note'] = r.pop('notes', None)
+ write_csv(zf, "caliper.csv", rows, ['id','date','chest','abdomen','thigh','tricep','subscapular','suprailiac','midaxillary','method','bf_percent','note','created'])
+
+ cur.execute("SELECT id, date, kcal, protein_g, fat_g, carbs_g, source, created FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ for r in rows:
+ r['meal_name'] = ''; r['protein'] = r.pop('protein_g', None)
+ r['fat'] = r.pop('fat_g', None); r['carbs'] = r.pop('carbs_g', None)
+ r['fiber'] = None; r['note'] = ''
+ write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
+
+ cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
+ rows = [r2d(r) for r in cur.fetchall()]
+ for r in rows:
+ r['name'] = r['activity_type']; r['type'] = r.pop('activity_type', None)
+ r['kcal'] = r.pop('kcal_active', None); r['heart_rate_avg'] = r.pop('hr_avg', None)
+ r['heart_rate_max'] = r.pop('hr_max', None); r['note'] = r.pop('notes', None)
+ write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created'])
+
+ # 8. insights/ai_insights.json
+ cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
+ insights = []
+ for r in cur.fetchall():
+ rd = r2d(r)
+ insights.append({
+ "id": rd['id'],
+ "scope": rd['scope'],
+ "created": rd['created'].isoformat() if hasattr(rd['created'], 'isoformat') else str(rd['created']),
+ "result": rd['content']
+ })
+ if insights:
+ zf.writestr("insights/ai_insights.json", json.dumps(insights, indent=2, ensure_ascii=False).encode('utf-8'))
+
+ # 9. photos/
+ cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
+ photos = [r2d(r) for r in cur.fetchall()]
+ for i, photo in enumerate(photos):
+ photo_path = Path(PHOTOS_DIR) / photo['path']
+ if photo_path.exists():
+ filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}"
+ zf.write(photo_path, f"photos/{filename}")
+
+ zip_buffer.seek(0)
+ filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
+ return StreamingResponse(
+ iter([zip_buffer.getvalue()]),
+ media_type="application/zip",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
diff --git a/backend/routers/importdata.py b/backend/routers/importdata.py
new file mode 100644
index 0000000..bd78e46
--- /dev/null
+++ b/backend/routers/importdata.py
@@ -0,0 +1,267 @@
+"""
+Data Import Endpoints for Mitai Jinkendo
+
+Handles ZIP import with validation and rollback support.
+"""
+import os
+import csv
+import io
+import json
+import uuid
+import zipfile
+from pathlib import Path
+from typing import Optional
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
+
+from db import get_db, get_cursor
+from auth import require_auth
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api/import", tags=["import"])
+
+PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
+
+
+@router.post("/zip")
+async def import_zip(
+ file: UploadFile = File(...),
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth)
+):
+ """
+ Import data from ZIP export file.
+
+ - Validates export format
+ - Imports missing entries only (ON CONFLICT DO NOTHING)
+ - Imports photos
+ - Returns import summary
+ - Full rollback on error
+ """
+ pid = get_pid(x_profile_id)
+
+ # Read uploaded file
+ content = await file.read()
+ zip_buffer = io.BytesIO(content)
+
+ try:
+ with zipfile.ZipFile(zip_buffer, 'r') as zf:
+ # 1. Validate profile.json
+ if 'profile.json' not in zf.namelist():
+ raise HTTPException(400, "Ungültiger Export: profile.json fehlt")
+
+ profile_data = json.loads(zf.read('profile.json').decode('utf-8'))
+ export_version = profile_data.get('export_version', '1')
+
+ # Stats tracker
+ stats = {
+ 'weight': 0,
+ 'circumferences': 0,
+ 'caliper': 0,
+ 'nutrition': 0,
+ 'activity': 0,
+ 'photos': 0,
+ 'insights': 0
+ }
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+
+ try:
+ # 2. Import weight.csv
+ if 'data/weight.csv' in zf.namelist():
+ csv_data = zf.read('data/weight.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO weight_log (profile_id, date, weight, note, source, created)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ ON CONFLICT (profile_id, date) DO NOTHING
+ """, (
+ pid,
+ row['date'],
+ float(row['weight']) if row['weight'] else None,
+ row.get('note', ''),
+ row.get('source', 'import'),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['weight'] += 1
+
+ # 3. Import circumferences.csv
+ if 'data/circumferences.csv' in zf.namelist():
+ csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO circumference_log (
+ profile_id, date, c_waist, c_hip, c_chest, c_neck,
+ c_arm, c_thigh, c_calf, notes, created
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ ON CONFLICT (profile_id, date) DO NOTHING
+ """, (
+ pid,
+ row['date'],
+ float(row['waist']) if row.get('waist') else None,
+ float(row['hip']) if row.get('hip') else None,
+ float(row['chest']) if row.get('chest') else None,
+ float(row['neck']) if row.get('neck') else None,
+ float(row['upper_arm']) if row.get('upper_arm') else None,
+ float(row['thigh']) if row.get('thigh') else None,
+ float(row['calf']) if row.get('calf') else None,
+ row.get('note', ''),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['circumferences'] += 1
+
+ # 4. Import caliper.csv
+ if 'data/caliper.csv' in zf.namelist():
+ csv_data = zf.read('data/caliper.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO caliper_log (
+ profile_id, date, sf_chest, sf_abdomen, sf_thigh,
+ sf_triceps, sf_subscap, sf_suprailiac, sf_axilla,
+ sf_method, body_fat_pct, notes, created
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ ON CONFLICT (profile_id, date) DO NOTHING
+ """, (
+ pid,
+ row['date'],
+ float(row['chest']) if row.get('chest') else None,
+ float(row['abdomen']) if row.get('abdomen') else None,
+ float(row['thigh']) if row.get('thigh') else None,
+ float(row['tricep']) if row.get('tricep') else None,
+ float(row['subscapular']) if row.get('subscapular') else None,
+ float(row['suprailiac']) if row.get('suprailiac') else None,
+ float(row['midaxillary']) if row.get('midaxillary') else None,
+ row.get('method', 'jackson3'),
+ float(row['bf_percent']) if row.get('bf_percent') else None,
+ row.get('note', ''),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['caliper'] += 1
+
+ # 5. Import nutrition.csv
+ if 'data/nutrition.csv' in zf.namelist():
+ csv_data = zf.read('data/nutrition.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO nutrition_log (
+ profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ ON CONFLICT (profile_id, date) DO NOTHING
+ """, (
+ pid,
+ row['date'],
+ float(row['kcal']) if row.get('kcal') else None,
+ float(row['protein']) if row.get('protein') else None,
+ float(row['fat']) if row.get('fat') else None,
+ float(row['carbs']) if row.get('carbs') else None,
+ row.get('source', 'import'),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['nutrition'] += 1
+
+ # 6. Import activity.csv
+ if 'data/activity.csv' in zf.namelist():
+ csv_data = zf.read('data/activity.csv').decode('utf-8-sig')
+ reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
+ for row in reader:
+ cur.execute("""
+ INSERT INTO activity_log (
+ profile_id, date, activity_type, duration_min,
+ kcal_active, hr_avg, hr_max, distance_km, notes, source, created
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ """, (
+ pid,
+ row['date'],
+ row.get('type', 'Training'),
+ float(row['duration_min']) if row.get('duration_min') else None,
+ float(row['kcal']) if row.get('kcal') else None,
+ float(row['heart_rate_avg']) if row.get('heart_rate_avg') else None,
+ float(row['heart_rate_max']) if row.get('heart_rate_max') else None,
+ float(row['distance_km']) if row.get('distance_km') else None,
+ row.get('note', ''),
+ row.get('source', 'import'),
+ row.get('created', datetime.now())
+ ))
+ if cur.rowcount > 0:
+ stats['activity'] += 1
+
+ # 7. Import ai_insights.json
+ if 'insights/ai_insights.json' in zf.namelist():
+ insights_data = json.loads(zf.read('insights/ai_insights.json').decode('utf-8'))
+ for insight in insights_data:
+ cur.execute("""
+ INSERT INTO ai_insights (profile_id, scope, content, created)
+ VALUES (%s, %s, %s, %s)
+ """, (
+ pid,
+ insight['scope'],
+ insight['result'],
+ insight.get('created', datetime.now())
+ ))
+ stats['insights'] += 1
+
+ # 8. Import photos
+ photo_files = [f for f in zf.namelist() if f.startswith('photos/') and not f.endswith('/')]
+ for photo_file in photo_files:
+ # Extract date from filename (format: YYYY-MM-DD_N.jpg)
+ filename = Path(photo_file).name
+ parts = filename.split('_')
+ photo_date = parts[0] if len(parts) > 0 else datetime.now().strftime('%Y-%m-%d')
+
+ # Generate new ID and path
+ photo_id = str(uuid.uuid4())
+ ext = Path(filename).suffix
+ new_filename = f"{photo_id}{ext}"
+ target_path = PHOTOS_DIR / new_filename
+
+ # Check if photo already exists for this date
+ cur.execute("""
+ SELECT id FROM photos
+ WHERE profile_id = %s AND date = %s
+ """, (pid, photo_date))
+
+ if cur.fetchone() is None:
+ # Write photo file
+ with open(target_path, 'wb') as f:
+ f.write(zf.read(photo_file))
+
+ # Insert DB record
+ cur.execute("""
+ INSERT INTO photos (id, profile_id, date, path, created)
+ VALUES (%s, %s, %s, %s, %s)
+ """, (photo_id, pid, photo_date, new_filename, datetime.now()))
+ stats['photos'] += 1
+
+ # Commit transaction
+ conn.commit()
+
+ except Exception as e:
+ # Rollback on any error
+ conn.rollback()
+ raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
+
+ return {
+ "ok": True,
+ "message": "Import erfolgreich",
+ "stats": stats,
+ "total": sum(stats.values())
+ }
+
+ except zipfile.BadZipFile:
+ raise HTTPException(400, "Ungültige ZIP-Datei")
+ except Exception as e:
+ raise HTTPException(500, f"Import-Fehler: {str(e)}")
diff --git a/backend/routers/insights.py b/backend/routers/insights.py
new file mode 100644
index 0000000..b325bf0
--- /dev/null
+++ b/backend/routers/insights.py
@@ -0,0 +1,460 @@
+"""
+AI Insights Endpoints for Mitai Jinkendo
+
+Handles AI analysis execution, prompt management, and usage tracking.
+"""
+import os
+import json
+import uuid
+import httpx
+from typing import Optional
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException, Header, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth, require_admin
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api", tags=["insights"])
+
+OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
+OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
+ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
+
+
+# ── Helper Functions ──────────────────────────────────────────────────────────
+def check_ai_limit(pid: str):
+ """Check if profile has reached daily AI limit. Returns (allowed, limit, used)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT ai_enabled, ai_limit_day FROM profiles WHERE id=%s", (pid,))
+ prof = cur.fetchone()
+ if not prof or not prof['ai_enabled']:
+ raise HTTPException(403, "KI ist für dieses Profil deaktiviert")
+ limit = prof['ai_limit_day']
+ if limit is None:
+ return (True, None, 0)
+ today = datetime.now().date().isoformat()
+ cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
+ usage = cur.fetchone()
+ used = usage['call_count'] if usage else 0
+ if used >= limit:
+ raise HTTPException(429, f"Tägliches KI-Limit erreicht ({limit} Calls)")
+ return (True, limit, used)
+
+
+def inc_ai_usage(pid: str):
+ """Increment AI usage counter for today."""
+ today = datetime.now().date().isoformat()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id, call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
+ row = cur.fetchone()
+ if row:
+ cur.execute("UPDATE ai_usage SET call_count=%s WHERE id=%s", (row['call_count']+1, row['id']))
+ else:
+ cur.execute("INSERT INTO ai_usage (id, profile_id, date, call_count) VALUES (%s,%s,%s,1)",
+ (str(uuid.uuid4()), pid, today))
+
+
+def _get_profile_data(pid: str):
+ """Fetch all relevant data for AI analysis."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ prof = r2d(cur.fetchone())
+ cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
+ weight = [r2d(r) for r in cur.fetchall()]
+ cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
+ circ = [r2d(r) for r in cur.fetchall()]
+ cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
+ caliper = [r2d(r) for r in cur.fetchall()]
+ cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
+ nutrition = [r2d(r) for r in cur.fetchall()]
+ cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
+ activity = [r2d(r) for r in cur.fetchall()]
+ return {
+ "profile": prof,
+ "weight": weight,
+ "circumference": circ,
+ "caliper": caliper,
+ "nutrition": nutrition,
+ "activity": activity
+ }
+
+
+def _render_template(template: str, data: dict) -> str:
+ """Simple template variable replacement."""
+ result = template
+ for k, v in data.items():
+ result = result.replace(f"{{{{{k}}}}}", str(v) if v is not None else "")
+ return result
+
+
+def _prepare_template_vars(data: dict) -> dict:
+ """Prepare template variables from profile data."""
+ prof = data['profile']
+ weight = data['weight']
+ circ = data['circumference']
+ caliper = data['caliper']
+ nutrition = data['nutrition']
+ activity = data['activity']
+
+ vars = {
+ "name": prof.get('name', 'Nutzer'),
+ "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich",
+ "height": prof.get('height', 178),
+ "goal_weight": float(prof.get('goal_weight')) if prof.get('goal_weight') else "nicht gesetzt",
+ "goal_bf_pct": float(prof.get('goal_bf_pct')) if prof.get('goal_bf_pct') else "nicht gesetzt",
+ "weight_aktuell": float(weight[0]['weight']) if weight else "keine Daten",
+ "kf_aktuell": float(caliper[0]['body_fat_pct']) if caliper and caliper[0].get('body_fat_pct') else "unbekannt",
+ }
+
+ # Calculate age from dob
+ if prof.get('dob'):
+ try:
+ from datetime import date
+ dob = datetime.strptime(prof['dob'], '%Y-%m-%d').date()
+ today = date.today()
+ age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
+ vars['age'] = age
+ except:
+ vars['age'] = "unbekannt"
+ else:
+ vars['age'] = "unbekannt"
+
+ # Weight trend summary
+ if len(weight) >= 2:
+ recent = weight[:30]
+ delta = float(recent[0]['weight']) - float(recent[-1]['weight'])
+ vars['weight_trend'] = f"{len(recent)} Einträge, Δ30d: {delta:+.1f}kg"
+ else:
+ vars['weight_trend'] = "zu wenig Daten"
+
+ # Caliper summary
+ if caliper:
+ c = caliper[0]
+ bf = float(c.get('body_fat_pct')) if c.get('body_fat_pct') else '?'
+ vars['caliper_summary'] = f"KF: {bf}%, Methode: {c.get('sf_method','?')}"
+ else:
+ vars['caliper_summary'] = "keine Daten"
+
+ # Circumference summary
+ if circ:
+ c = circ[0]
+ parts = []
+ for k in ['c_waist', 'c_belly', 'c_hip']:
+ if c.get(k): parts.append(f"{k.split('_')[1]}: {float(c[k])}cm")
+ vars['circ_summary'] = ", ".join(parts) if parts else "keine Daten"
+ else:
+ vars['circ_summary'] = "keine Daten"
+
+ # Nutrition summary
+ if nutrition:
+ n = len(nutrition)
+ avg_kcal = sum(float(d.get('kcal',0) or 0) for d in nutrition) / n
+ avg_prot = sum(float(d.get('protein_g',0) or 0) for d in nutrition) / n
+ vars['nutrition_summary'] = f"{n} Tage, Ø {avg_kcal:.0f}kcal, {avg_prot:.0f}g Protein"
+ vars['nutrition_detail'] = vars['nutrition_summary']
+ vars['nutrition_days'] = n
+ vars['kcal_avg'] = round(avg_kcal)
+ vars['protein_avg'] = round(avg_prot,1)
+ vars['fat_avg'] = round(sum(float(d.get('fat_g',0) or 0) for d in nutrition) / n,1)
+ vars['carb_avg'] = round(sum(float(d.get('carbs_g',0) or 0) for d in nutrition) / n,1)
+ else:
+ vars['nutrition_summary'] = "keine Daten"
+ vars['nutrition_detail'] = "keine Daten"
+ vars['nutrition_days'] = 0
+ vars['kcal_avg'] = 0
+ vars['protein_avg'] = 0
+ vars['fat_avg'] = 0
+ vars['carb_avg'] = 0
+
+ # Protein targets
+ w = weight[0]['weight'] if weight else prof.get('height',178) - 100
+ w = float(w) # Convert Decimal to float for math operations
+ vars['protein_ziel_low'] = round(w * 1.6)
+ vars['protein_ziel_high'] = round(w * 2.2)
+
+ # Activity summary
+ if activity:
+ n = len(activity)
+ total_kcal = sum(float(a.get('kcal_active',0) or 0) for a in activity)
+ vars['activity_summary'] = f"{n} Trainings, {total_kcal:.0f}kcal gesamt"
+ vars['activity_detail'] = vars['activity_summary']
+ vars['activity_kcal_summary'] = f"Ø {total_kcal/n:.0f}kcal/Training"
+ else:
+ vars['activity_summary'] = "keine Daten"
+ vars['activity_detail'] = "keine Daten"
+ vars['activity_kcal_summary'] = "keine Daten"
+
+ return vars
+
+
+# ── Endpoints ─────────────────────────────────────────────────────────────────
+@router.get("/insights")
+def get_all_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get all AI insights for profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
+ rows = cur.fetchall()
+ return [r2d(r) for r in rows]
+
+
+@router.get("/insights/latest")
+def get_latest_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get latest AI insights across all scopes."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC LIMIT 10", (pid,))
+ rows = cur.fetchall()
+ return [r2d(r) for r in rows]
+
+
+@router.get("/ai/insights/{scope}")
+def get_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get latest insight for specific scope."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s AND scope=%s ORDER BY created DESC LIMIT 1", (pid,scope))
+ row = cur.fetchone()
+ if not row: return None
+ return r2d(row)
+
+
+@router.delete("/insights/{insight_id}")
+def delete_insight_by_id(insight_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Delete a specific insight by ID."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM ai_insights WHERE id=%s AND profile_id=%s", (insight_id, pid))
+ return {"ok":True}
+
+
+@router.delete("/ai/insights/{scope}")
+def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Delete all insights for specific scope."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid,scope))
+ return {"ok":True}
+
+
+@router.post("/insights/run/{slug}")
+async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Run AI analysis with specified prompt template."""
+ pid = get_pid(x_profile_id)
+ check_ai_limit(pid)
+
+ # Get prompt template
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=true", (slug,))
+ prompt_row = cur.fetchone()
+ if not prompt_row:
+ raise HTTPException(404, f"Prompt '{slug}' nicht gefunden")
+
+ prompt_tmpl = prompt_row['template']
+ data = _get_profile_data(pid)
+ vars = _prepare_template_vars(data)
+ final_prompt = _render_template(prompt_tmpl, vars)
+
+ # Call AI
+ if ANTHROPIC_KEY:
+ # Use Anthropic SDK
+ import anthropic
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
+ response = client.messages.create(
+ model="claude-sonnet-4-20250514",
+ max_tokens=2000,
+ messages=[{"role": "user", "content": final_prompt}]
+ )
+ content = response.content[0].text
+ elif OPENROUTER_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
+ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
+ json={
+ "model": OPENROUTER_MODEL,
+ "messages": [{"role": "user", "content": final_prompt}],
+ "max_tokens": 2000
+ },
+ timeout=60.0
+ )
+ if resp.status_code != 200:
+ raise HTTPException(500, f"KI-Fehler: {resp.text}")
+ content = resp.json()['choices'][0]['message']['content']
+ else:
+ raise HTTPException(500, "Keine KI-API konfiguriert")
+
+ # Save insight
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug))
+ cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
+ (str(uuid.uuid4()), pid, slug, content))
+
+ inc_ai_usage(pid)
+ return {"scope": slug, "content": content}
+
+
+@router.post("/insights/pipeline")
+async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Run 3-stage pipeline analysis."""
+ pid = get_pid(x_profile_id)
+ check_ai_limit(pid)
+
+ data = _get_profile_data(pid)
+ vars = _prepare_template_vars(data)
+
+ # Stage 1: Parallel JSON analyses
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT slug, template FROM ai_prompts WHERE slug LIKE 'pipeline_%' AND slug NOT IN ('pipeline_synthesis','pipeline_goals') AND active=true")
+ stage1_prompts = [r2d(r) for r in cur.fetchall()]
+
+ stage1_results = {}
+ for p in stage1_prompts:
+ slug = p['slug']
+ final_prompt = _render_template(p['template'], vars)
+
+ if ANTHROPIC_KEY:
+ import anthropic
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
+ response = client.messages.create(
+ model="claude-sonnet-4-20250514",
+ max_tokens=1000,
+ messages=[{"role": "user", "content": final_prompt}]
+ )
+ content = response.content[0].text.strip()
+ elif OPENROUTER_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
+ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
+ json={
+ "model": OPENROUTER_MODEL,
+ "messages": [{"role": "user", "content": final_prompt}],
+ "max_tokens": 1000
+ },
+ timeout=60.0
+ )
+ content = resp.json()['choices'][0]['message']['content'].strip()
+ else:
+ raise HTTPException(500, "Keine KI-API konfiguriert")
+
+ # Try to parse JSON, fallback to raw text
+ try:
+ stage1_results[slug] = json.loads(content)
+ except:
+ stage1_results[slug] = content
+
+ # Stage 2: Synthesis
+ vars['stage1_body'] = json.dumps(stage1_results.get('pipeline_body', {}), ensure_ascii=False)
+ vars['stage1_nutrition'] = json.dumps(stage1_results.get('pipeline_nutrition', {}), ensure_ascii=False)
+ vars['stage1_activity'] = json.dumps(stage1_results.get('pipeline_activity', {}), ensure_ascii=False)
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=true")
+ synth_row = cur.fetchone()
+ if not synth_row:
+ raise HTTPException(500, "Pipeline synthesis prompt not found")
+
+ synth_prompt = _render_template(synth_row['template'], vars)
+
+ if ANTHROPIC_KEY:
+ import anthropic
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
+ response = client.messages.create(
+ model="claude-sonnet-4-20250514",
+ max_tokens=2000,
+ messages=[{"role": "user", "content": synth_prompt}]
+ )
+ synthesis = response.content[0].text
+ elif OPENROUTER_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
+ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
+ json={
+ "model": OPENROUTER_MODEL,
+ "messages": [{"role": "user", "content": synth_prompt}],
+ "max_tokens": 2000
+ },
+ timeout=60.0
+ )
+ synthesis = resp.json()['choices'][0]['message']['content']
+ else:
+ raise HTTPException(500, "Keine KI-API konfiguriert")
+
+ # Stage 3: Goals (only if goals are set)
+ goals_text = None
+ prof = data['profile']
+ if prof.get('goal_weight') or prof.get('goal_bf_pct'):
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_goals' AND active=true")
+ goals_row = cur.fetchone()
+ if goals_row:
+ goals_prompt = _render_template(goals_row['template'], vars)
+
+ if ANTHROPIC_KEY:
+ import anthropic
+ client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
+ response = client.messages.create(
+ model="claude-sonnet-4-20250514",
+ max_tokens=800,
+ messages=[{"role": "user", "content": goals_prompt}]
+ )
+ goals_text = response.content[0].text
+ elif OPENROUTER_KEY:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post("https://openrouter.ai/api/v1/chat/completions",
+ headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
+ json={
+ "model": OPENROUTER_MODEL,
+ "messages": [{"role": "user", "content": goals_prompt}],
+ "max_tokens": 800
+ },
+ timeout=60.0
+ )
+ goals_text = resp.json()['choices'][0]['message']['content']
+
+ # Combine synthesis + goals
+ final_content = synthesis
+ if goals_text:
+ final_content += "\n\n" + goals_text
+
+ # Save as 'gesamt' scope
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,))
+ cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
+ (str(uuid.uuid4()), pid, final_content))
+
+ inc_ai_usage(pid)
+ return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}
+
+
+@router.get("/ai/usage")
+def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get AI usage stats for current profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT ai_limit_day FROM profiles WHERE id=%s", (pid,))
+ prof = cur.fetchone()
+ limit = prof['ai_limit_day'] if prof else None
+
+ today = datetime.now().date().isoformat()
+ cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today))
+ usage = cur.fetchone()
+ used = usage['call_count'] if usage else 0
+
+ return {"limit": limit, "used": used, "remaining": (limit - used) if limit else None}
diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py
new file mode 100644
index 0000000..e12b4c0
--- /dev/null
+++ b/backend/routers/nutrition.py
@@ -0,0 +1,133 @@
+"""
+Nutrition Tracking Endpoints for Mitai Jinkendo
+
+Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates.
+"""
+import csv
+import io
+import uuid
+from typing import Optional
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
+
+
+# ── Helper ────────────────────────────────────────────────────────────────────
+def _pf(s):
+ """Parse float from string (handles comma decimal separator)."""
+ try: return float(str(s).replace(',','.').strip())
+ except: return 0.0
+
+
+# ── Endpoints ─────────────────────────────────────────────────────────────────
+@router.post("/import-csv")
+async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Import FDDB nutrition CSV."""
+ pid = get_pid(x_profile_id)
+ raw = await file.read()
+ try: text = raw.decode('utf-8')
+ except: text = raw.decode('latin-1')
+ if text.startswith('\ufeff'): text = text[1:]
+ if not text.strip(): raise HTTPException(400,"Leere Datei")
+ reader = csv.DictReader(io.StringIO(text), delimiter=';')
+ days: dict = {}
+ count = 0
+ for row in reader:
+ rd = row.get('datum_tag_monat_jahr_stunde_minute','').strip().strip('"')
+ if not rd: continue
+ try:
+ p = rd.split(' ')[0].split('.')
+ iso = f"{p[2]}-{p[1]}-{p[0]}"
+ except: continue
+ days.setdefault(iso,{'kcal':0,'fat_g':0,'carbs_g':0,'protein_g':0})
+ days[iso]['kcal'] += _pf(row.get('kj',0))/4.184
+ days[iso]['fat_g'] += _pf(row.get('fett_g',0))
+ days[iso]['carbs_g'] += _pf(row.get('kh_g',0))
+ days[iso]['protein_g'] += _pf(row.get('protein_g',0))
+ count+=1
+ inserted=0
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ for iso,vals in days.items():
+ kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
+ carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
+ cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
+ if cur.fetchone():
+ cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
+ (kcal,prot,fat,carbs,pid,iso))
+ else:
+ cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
+ (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
+ inserted+=1
+ return {"rows_parsed":count,"days_imported":inserted,
+ "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
+
+
+@router.get("")
+def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get nutrition entries for current profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.get("/correlations")
+def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get nutrition data correlated with weight and body fat."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date",(pid,))
+ nutr={r['date']:r2d(r) for r in cur.fetchall()}
+ cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,))
+ wlog={r['date']:r['weight'] for r in cur.fetchall()}
+ cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,))
+ cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date'])
+ all_dates=sorted(set(list(nutr)+list(wlog)))
+ mi,last_cal,cal_by_date=0,{},{}
+ for d in all_dates:
+ while mi tags)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT path FROM photos WHERE id=%s", (fid,))
+ row = cur.fetchone()
+ if not row: raise HTTPException(404, "Photo not found")
+ photo_path = Path(PHOTOS_DIR) / row['path']
+ if not photo_path.exists():
+ raise HTTPException(404, "Photo file not found")
+ return FileResponse(photo_path)
+
+
+@router.get("")
+def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get all photos for current profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,))
+ return [r2d(r) for r in cur.fetchall()]
diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py
new file mode 100644
index 0000000..f2ebe05
--- /dev/null
+++ b/backend/routers/profiles.py
@@ -0,0 +1,107 @@
+"""
+Profile Management Endpoints for Mitai Jinkendo
+
+Handles profile CRUD operations for both admin and current user.
+"""
+import uuid
+from typing import Optional
+from datetime import datetime
+
+from fastapi import APIRouter, HTTPException, Header, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth
+from models import ProfileCreate, ProfileUpdate
+
+router = APIRouter(prefix="/api", tags=["profiles"])
+
+
+# ── Helper ────────────────────────────────────────────────────────────────────
+def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
+ """Get profile_id - from header for legacy endpoints."""
+ if x_profile_id:
+ return x_profile_id
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
+ row = cur.fetchone()
+ if row: return row['id']
+ raise HTTPException(400, "Kein Profil gefunden")
+
+
+# ── Admin Profile Management ──────────────────────────────────────────────────
+@router.get("/profiles")
+def list_profiles(session=Depends(require_auth)):
+ """List all profiles (admin)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles ORDER BY created")
+ rows = cur.fetchall()
+ return [r2d(r) for r in rows]
+
+
+@router.post("/profiles")
+def create_profile(p: ProfileCreate, session=Depends(require_auth)):
+ """Create new profile (admin)."""
+ pid = str(uuid.uuid4())
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""",
+ (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct))
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ return r2d(cur.fetchone())
+
+
+@router.get("/profiles/{pid}")
+def get_profile(pid: str, session=Depends(require_auth)):
+ """Get profile by ID."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
+ row = cur.fetchone()
+ if not row: raise HTTPException(404, "Profil nicht gefunden")
+ return r2d(row)
+
+
+@router.put("/profiles/{pid}")
+def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
+ """Update profile by ID (admin)."""
+ with get_db() as conn:
+ data = {k:v for k,v in p.model_dump().items() if v is not None}
+ data['updated'] = datetime.now().isoformat()
+ cur = get_cursor(conn)
+ cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s",
+ list(data.values())+[pid])
+ return get_profile(pid, session)
+
+
+@router.delete("/profiles/{pid}")
+def delete_profile(pid: str, session=Depends(require_auth)):
+ """Delete profile (admin)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT COUNT(*) as count FROM profiles")
+ count = cur.fetchone()['count']
+ if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
+ for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']:
+ cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
+ cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
+ return {"ok": True}
+
+
+# ── Current User Profile ──────────────────────────────────────────────────────
+@router.get("/profile")
+def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
+ """Legacy endpoint – returns active profile."""
+ pid = get_pid(x_profile_id)
+ return get_profile(pid, session)
+
+
+@router.put("/profile")
+def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
+ """Update current user's profile."""
+ pid = get_pid(x_profile_id)
+ return update_profile(pid, p, session)
diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py
new file mode 100644
index 0000000..a0999ad
--- /dev/null
+++ b/backend/routers/prompts.py
@@ -0,0 +1,60 @@
+"""
+AI Prompts Management Endpoints for Mitai Jinkendo
+
+Handles prompt template configuration (admin-editable).
+"""
+from fastapi import APIRouter, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth, require_admin
+
+router = APIRouter(prefix="/api/prompts", tags=["prompts"])
+
+
+@router.get("")
+def list_prompts(session: dict=Depends(require_auth)):
+ """
+ List AI prompts.
+ - Admins: see ALL prompts (including pipeline and inactive)
+ - Users: see only active single-analysis prompts
+ """
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ is_admin = session.get('role') == 'admin'
+
+ if is_admin:
+ # Admin sees everything
+ cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug")
+ else:
+ # Users see only active, non-pipeline prompts
+ cur.execute("SELECT * FROM ai_prompts WHERE active=true AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order")
+
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.put("/{prompt_id}")
+def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)):
+ """Update AI prompt template (admin only)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ updates = []
+ values = []
+ if 'name' in data:
+ updates.append('name=%s')
+ values.append(data['name'])
+ if 'description' in data:
+ updates.append('description=%s')
+ values.append(data['description'])
+ if 'template' in data:
+ updates.append('template=%s')
+ values.append(data['template'])
+ if 'active' in data:
+ updates.append('active=%s')
+ # Convert to boolean (accepts true/false, 1/0)
+ values.append(bool(data['active']))
+
+ if updates:
+ cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s",
+ values + [prompt_id])
+
+ return {"ok": True}
diff --git a/backend/routers/stats.py b/backend/routers/stats.py
new file mode 100644
index 0000000..eb56776
--- /dev/null
+++ b/backend/routers/stats.py
@@ -0,0 +1,39 @@
+"""
+Statistics Endpoints for Mitai Jinkendo
+
+Dashboard statistics showing entry counts across all categories.
+"""
+from typing import Optional
+
+from fastapi import APIRouter, Header, Depends
+
+from db import get_db, get_cursor
+from auth import require_auth
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api", tags=["stats"])
+
+
+@router.get("/stats")
+def get_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get entry counts for all tracking categories."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s",(pid,))
+ weight_count = cur.fetchone()['count']
+ cur.execute("SELECT COUNT(*) as count FROM circumference_log WHERE profile_id=%s",(pid,))
+ circ_count = cur.fetchone()['count']
+ cur.execute("SELECT COUNT(*) as count FROM caliper_log WHERE profile_id=%s",(pid,))
+ caliper_count = cur.fetchone()['count']
+ cur.execute("SELECT COUNT(*) as count FROM nutrition_log WHERE profile_id=%s",(pid,))
+ nutrition_count = cur.fetchone()['count']
+ cur.execute("SELECT COUNT(*) as count FROM activity_log WHERE profile_id=%s",(pid,))
+ activity_count = cur.fetchone()['count']
+ return {
+ "weight_count": weight_count,
+ "circ_count": circ_count,
+ "caliper_count": caliper_count,
+ "nutrition_count": nutrition_count,
+ "activity_count": activity_count
+ }
diff --git a/backend/routers/weight.py b/backend/routers/weight.py
new file mode 100644
index 0000000..0d66713
--- /dev/null
+++ b/backend/routers/weight.py
@@ -0,0 +1,81 @@
+"""
+Weight Tracking Endpoints for Mitai Jinkendo
+
+Handles weight log CRUD operations and statistics.
+"""
+import uuid
+from typing import Optional
+
+from fastapi import APIRouter, Header, Depends
+
+from db import get_db, get_cursor, r2d
+from auth import require_auth
+from models import WeightEntry
+from routers.profiles import get_pid
+
+router = APIRouter(prefix="/api/weight", tags=["weight"])
+
+
+@router.get("")
+def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get weight entries for current profile."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit))
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("")
+def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Create or update weight entry (upsert by date)."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
+ ex = cur.fetchone()
+ if ex:
+ cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id']))
+ wid = ex['id']
+ else:
+ wid = str(uuid.uuid4())
+ cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)",
+ (wid,pid,e.date,e.weight,e.note))
+ return {"id":wid,"date":e.date,"weight":e.weight}
+
+
+@router.put("/{wid}")
+def update_weight(wid: str, e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Update existing weight entry."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("UPDATE weight_log SET date=%s,weight=%s,note=%s WHERE id=%s AND profile_id=%s",
+ (e.date,e.weight,e.note,wid,pid))
+ return {"id":wid}
+
+
+@router.delete("/{wid}")
+def delete_weight(wid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Delete weight entry."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("DELETE FROM weight_log WHERE id=%s AND profile_id=%s", (wid,pid))
+ return {"ok":True}
+
+
+@router.get("/stats")
+def weight_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
+ """Get weight statistics (last 90 days)."""
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
+ rows = cur.fetchall()
+ if not rows: return {"count":0,"latest":None,"prev":None,"min":None,"max":None,"avg_7d":None}
+ w=[float(r['weight']) for r in rows]
+ return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":float(rows[0]['weight'])},
+ "prev":{"date":rows[1]['date'],"weight":float(rows[1]['weight'])} if len(rows)>1 else None,
+ "min":min(w),"max":max(w),"avg_7d":round(sum(w[:7])/min(7,len(w)),2)}