From 0a871fea22321ff1a0e08357b4fe39b99dd8467f Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 08:27:33 +0100 Subject: [PATCH] 9b --- .env.example | 12 +- .gitignore | 3 + CLAUDE.md | 151 +++ backend/Dockerfile | 17 +- backend/db.py | 150 +++ backend/main.py | 2233 +++++++++++--------------------- backend/migrate_to_postgres.py | 369 ++++++ backend/requirements.txt | 1 + backend/schema.sql | 260 ++++ backend/startup.sh | 73 ++ docker-compose.dev-env.yml | 38 +- docker-compose.yml | 39 +- 12 files changed, 1834 insertions(+), 1512 deletions(-) create mode 100644 backend/db.py create mode 100644 backend/migrate_to_postgres.py create mode 100644 backend/schema.sql create mode 100644 backend/startup.sh diff --git a/.env.example b/.env.example index 0da4e89..efafe9f 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ -# ── Datenbank ────────────────────────────────────────────────── -# v9 (PostgreSQL): -DB_PASSWORD=sicheres_passwort_hier - -# v8 (SQLite, legacy): -# DATA_DIR=/app/data +# ── Datenbank (PostgreSQL v9b+) ──────────────────────────────── +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=mitai +DB_USER=mitai +DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD_HERE # ── KI ───────────────────────────────────────────────────────── # OpenRouter (empfohlen): diff --git a/.gitignore b/.gitignore index d564af3..41e4430 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ coverage/ # Temp tmp/ *.tmp + +#.claude Konfiguration +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 13cf7aa..887af36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -293,3 +293,154 @@ Wortmarke: Jin(light) + ken(bold #1D9E75) + do(light) /add-endpoint → Neuen API-Endpoint hinzufügen /db-add-column → Neue DB-Spalte hinzufügen ``` + +## Jinkendo App-Familie & Markenarchitektur + +### Philosophie +**Jinkendo** (人拳道) = Jin (人 Mensch) + Ken (拳 Faust) + Do (道 Weg) +"Der menschliche Weg der Kampfkunst" – ruhig aber kraftvoll, Selbstwahrnehmung, Meditation, Zielorientiert + +### App-Familie (Subdomain-Architektur) +``` +mitai.jinkendo.de → Körper-Tracker (身体 = eigener Körper) ← DIESE APP +miken.jinkendo.de → Meditation (眉間 = drittes Auge) +ikigai.jinkendo.de → Lebenssinn/Ziele (生き甲斐) +shinkan.jinkendo.de → Kampfsport (真観 = wahre Wahrnehmung) +kenkou.jinkendo.de → Gesundheit allgemein (健康) – für später aufsparen +``` + +### Registrierte Domains +- jinkendo.de, jinkendo.com, jinkendo.life – alle registriert bei Strato + +## v9b Detailplan – Freemium Tier-System + +### Tier-Modell +``` +free → Selbst-Registrierung, 14-Tage Trial, eingeschränkt +basic → Kernfunktionen (Abo Stufe 1) +premium → Alles inkl. KI und Connectoren (Abo Stufe 2) +selfhosted → Lars' Heimversion, keine Einschränkungen +``` + +### Geplante DB-Erweiterungen (profiles Tabelle) +```sql +tier TEXT DEFAULT 'free' +trial_ends_at TEXT -- ISO datetime +sub_valid_until TEXT -- ISO datetime +email_verified INTEGER DEFAULT 0 +email_verify_token TEXT +invited_by TEXT -- profile_id FK +invitation_token TEXT +``` + +### Tier-Limits (geplant) +| Feature | free | basic | premium | selfhosted | +|---------|------|-------|---------|------------| +| Gewicht-Einträge | 30 | unbegrenzt | unbegrenzt | unbegrenzt | +| KI-Analysen/Monat | 0 | 3 | unbegrenzt | unbegrenzt | +| Ernährung Import | ❌ | ✅ | ✅ | ✅ | +| Export | ❌ | ✅ | ✅ | ✅ | +| Fitness-Connectoren | ❌ | ❌ | ✅ | ✅ | + +### Registrierungs-Flow (geplant) +``` +1. Selbst-Registrierung: Name + E-Mail + Passwort +2. Auto-Trial: tier='free', trial_ends_at=now+14d +3. E-Mail-Bestätigung → email_verified=1 +4. Trial läuft ab → Upgrade-Prompt +5. Einladungslinks: Admin generiert Token → direkt basic-Tier +6. Stripe Integration: später (v9b ohne Stripe, nur Tier-Logik) +``` + +## Infrastruktur Details + +### Heimnetzwerk +``` +Internet + → Fritz!Box 7530 AX (DynDNS: privat.stommer.com) + → Synology NAS (192.168.2.63, Reverse Proxy + Let's Encrypt) + → Raspberry Pi 5 (192.168.2.49, Docker) + → MiniPC (192.168.2.144, Gitea auf Port 3000) +``` + +### Synology Reverse Proxy Regeln +``` +mitai.jinkendo.de → HTTP 192.168.2.49:3002 (Prod Frontend) +dev.mitai.jinkendo.de → HTTP 192.168.2.49:3099 (Dev Frontend) +``` + +### AdGuard DNS Rewrites (für internes Routing) +``` +mitai.jinkendo.de → 192.168.2.63 +dev.mitai.jinkendo.de → 192.168.2.63 +``` + +### Fritz!Box DNS-Rebind Ausnahmen +``` +jinkendo.de +mitai.jinkendo.de +``` + +### Pi Verzeichnisstruktur +``` +/home/lars/docker/ +├── bodytrack/ → Prod (main branch, docker-compose.yml) +└── bodytrack-dev/ → Dev (develop branch, docker-compose.dev-env.yml) +``` + +### Gitea Runner +``` +Runner: raspberry-pi (auf Pi installiert) +Service: /etc/systemd/system/gitea-runner.service +Binary: /home/lars/gitea-runner/act_runner +``` + +### Container Namen +``` +Prod: mitai-api, mitai-ui +Dev: dev-mitai-api, dev-mitai-ui +``` + +## Bekannte Probleme & Lösungen + +### dayjs.week() – NIEMALS verwenden +```javascript +// ❌ Falsch: +const week = dayjs(date).week() + +// ✅ Richtig (ISO 8601): +const weekNum = (() => { + const dt = new Date(date) + dt.setHours(0,0,0,0) + dt.setDate(dt.getDate()+4-(dt.getDay()||7)) + const y = new Date(dt.getFullYear(),0,1) + return Math.ceil(((dt-y)/86400000+1)/7) +})() +``` + +### session=Depends(require_auth) – Korrekte Platzierung +```python +# ❌ Falsch (führt zu NameError oder ungeschütztem Endpoint): +def endpoint(x_profile_id: Optional[str] = Header(default=None, session=Depends(require_auth))): + +# ✅ Richtig (separater Parameter): +def endpoint(x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth)): +``` + +### Recharts Bar fill=function – nicht unterstützt +```jsx +// ❌ Falsch: + entry.color}/> + +// ✅ Richtig: + +``` + +### SQLite neue Spalten hinzufügen +```python +# In _safe_alters Liste hinzufügen (NICHT direkt ALTER TABLE): +_safe_alters = [ + ("profiles", "neue_spalte TEXT DEFAULT NULL"), +] +``` diff --git a/backend/Dockerfile b/backend/Dockerfile index a3745aa..2caeaf5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,22 @@ FROM python:3.12-slim + +# Install PostgreSQL client for psql (needed for startup.sh) +RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/* + WORKDIR /app + +# Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code COPY . . + +# Create directories RUN mkdir -p /app/data /app/photos -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] + +# Make startup script executable +RUN chmod +x /app/startup.sh + +# Use startup script instead of direct uvicorn +CMD ["/app/startup.sh"] diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..d6dff6a --- /dev/null +++ b/backend/db.py @@ -0,0 +1,150 @@ +""" +PostgreSQL Database Connector for Mitai Jinkendo (v9b) + +Provides connection pooling and helper functions for database operations. +Compatible drop-in replacement for the previous SQLite get_db() pattern. +""" +import os +from contextlib import contextmanager +from typing import Optional, Dict, Any, List +import psycopg2 +from psycopg2.extras import RealDictCursor +import psycopg2.pool + + +# Global connection pool +_pool: Optional[psycopg2.pool.SimpleConnectionPool] = None + + +def init_pool(): + """Initialize PostgreSQL connection pool.""" + global _pool + if _pool is None: + _pool = psycopg2.pool.SimpleConnectionPool( + minconn=1, + maxconn=10, + host=os.getenv("DB_HOST", "postgres"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "mitai"), + user=os.getenv("DB_USER", "mitai"), + password=os.getenv("DB_PASSWORD", "") + ) + print(f"✓ PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})") + + +@contextmanager +def get_db(): + """ + Context manager for database connections. + + Usage: + with get_db() as conn: + cur = conn.cursor() + cur.execute("SELECT * FROM profiles") + rows = cur.fetchall() + + Auto-commits on success, auto-rolls back on exception. + """ + if _pool is None: + init_pool() + + conn = _pool.getconn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + _pool.putconn(conn) + + +def get_cursor(conn): + """ + Get cursor with RealDictCursor for dict-like row access. + + Returns rows as dictionaries: {'column_name': value, ...} + Compatible with previous sqlite3.Row behavior. + """ + return conn.cursor(cursor_factory=RealDictCursor) + + +def r2d(row) -> Optional[Dict[str, Any]]: + """ + Convert row to dict (compatibility helper). + + Args: + row: RealDictRow from psycopg2 + + Returns: + Dictionary or None if row is None + """ + return dict(row) if row else None + + +def execute_one(conn, query: str, params: tuple = ()) -> Optional[Dict[str, Any]]: + """ + Execute query and return one row as dict. + + Args: + conn: Database connection from get_db() + query: SQL query with %s placeholders + params: Tuple of parameters + + Returns: + Dictionary with column:value pairs, or None if no row found + + Example: + profile = execute_one(conn, "SELECT * FROM profiles WHERE id=%s", (pid,)) + if profile: + print(profile['name']) + """ + with get_cursor(conn) as cur: + cur.execute(query, params) + row = cur.fetchone() + return r2d(row) + + +def execute_all(conn, query: str, params: tuple = ()) -> List[Dict[str, Any]]: + """ + Execute query and return all rows as list of dicts. + + Args: + conn: Database connection from get_db() + query: SQL query with %s placeholders + params: Tuple of parameters + + Returns: + List of dictionaries (one per row) + + Example: + weights = execute_all(conn, + "SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC", + (pid,) + ) + for w in weights: + print(w['date'], w['weight']) + """ + with get_cursor(conn) as cur: + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +def execute_write(conn, query: str, params: tuple = ()) -> None: + """ + Execute INSERT/UPDATE/DELETE query. + + Args: + conn: Database connection from get_db() + query: SQL query with %s placeholders + params: Tuple of parameters + + Example: + execute_write(conn, + "UPDATE profiles SET name=%s WHERE id=%s", + ("New Name", pid) + ) + """ + with get_cursor(conn) as cur: + cur.execute(query, params) diff --git a/backend/main.py b/backend/main.py index 8876ee7..103ada8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,16 +7,17 @@ from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Dep from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, FileResponse from pydantic import BaseModel -import sqlite3, aiofiles +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, r2d + DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) -DB_PATH = DATA_DIR / "bodytrack.db" DATA_DIR.mkdir(parents=True, exist_ok=True) PHOTOS_DIR.mkdir(parents=True, exist_ok=True) @@ -36,470 +37,13 @@ app.add_middleware( allow_headers=["*"], ) -def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - -def r2d(row): return dict(row) if row else None - AVATAR_COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780'] def init_db(): - with get_db() as conn: - conn.executescript(""" - -- Profiles (multi-user) - CREATE TABLE IF NOT EXISTS profiles ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT 'Nutzer', - avatar_color TEXT DEFAULT '#1D9E75', - photo_id TEXT, - sex TEXT DEFAULT 'm', - dob TEXT, - height REAL DEFAULT 178, - goal_weight REAL, - goal_bf_pct REAL, - role TEXT DEFAULT 'user', - pin_hash TEXT, - auth_type TEXT DEFAULT 'pin', - session_days INTEGER DEFAULT 30, - ai_enabled INTEGER DEFAULT 1, - ai_limit_day INTEGER, - export_enabled INTEGER DEFAULT 1, - email TEXT, - created TEXT DEFAULT (datetime('now')), - updated TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS sessions ( - token TEXT PRIMARY KEY, - profile_id TEXT NOT NULL, - expires_at TEXT NOT NULL, - created TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS ai_usage ( - id TEXT PRIMARY KEY, - profile_id TEXT NOT NULL, - date TEXT NOT NULL, - call_count INTEGER DEFAULT 0 - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_usage ON ai_usage(profile_id, date); - - -- Weight - CREATE TABLE IF NOT EXISTS weight_log ( - id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, - date TEXT NOT NULL, weight REAL NOT NULL, - note TEXT, source TEXT DEFAULT 'manual', - created TEXT DEFAULT (datetime('now')) - ); - -- Circumferences - CREATE TABLE IF NOT EXISTS circumference_log ( - id TEXT PRIMARY KEY, profile_id TEXT, - date TEXT NOT NULL, - c_neck REAL, c_chest REAL, c_waist REAL, c_belly REAL, - c_hip REAL, c_thigh REAL, c_calf REAL, c_arm REAL, - notes TEXT, photo_id TEXT, - created TEXT DEFAULT (datetime('now')) - ); - -- Caliper - CREATE TABLE IF NOT EXISTS caliper_log ( - id TEXT PRIMARY KEY, profile_id TEXT, - date TEXT NOT NULL, - sf_method TEXT DEFAULT 'jackson3', - sf_chest REAL, sf_axilla REAL, sf_triceps REAL, sf_subscap REAL, - sf_suprailiac REAL, sf_abdomen REAL, sf_thigh REAL, - sf_calf_med REAL, sf_lowerback REAL, sf_biceps REAL, - body_fat_pct REAL, lean_mass REAL, fat_mass REAL, - notes TEXT, created TEXT DEFAULT (datetime('now')) - ); - -- Nutrition - CREATE TABLE IF NOT EXISTS nutrition_log ( - id TEXT PRIMARY KEY, profile_id TEXT, - date TEXT NOT NULL, kcal REAL, protein_g REAL, fat_g REAL, carbs_g REAL, - source TEXT DEFAULT 'csv', created TEXT DEFAULT (datetime('now')) - ); - -- Activity - CREATE TABLE IF NOT EXISTS activity_log ( - id TEXT PRIMARY KEY, profile_id TEXT, - date TEXT NOT NULL, start_time TEXT, end_time TEXT, - activity_type TEXT NOT NULL, duration_min REAL, - kcal_active REAL, kcal_resting REAL, - hr_avg REAL, hr_max REAL, distance_km REAL, - rpe INTEGER, source TEXT DEFAULT 'manual', notes TEXT, - created TEXT DEFAULT (datetime('now')) - ); - - -- Photos - CREATE TABLE IF NOT EXISTS photos ( - id TEXT PRIMARY KEY, profile_id TEXT, date TEXT, path TEXT, - created TEXT DEFAULT (datetime('now')) - ); - - -- AI insights - CREATE TABLE IF NOT EXISTS ai_insights ( - id TEXT PRIMARY KEY, profile_id TEXT, scope TEXT, content TEXT, - created TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS ai_prompts ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - slug TEXT NOT NULL UNIQUE, - description TEXT, - template TEXT NOT NULL, - active INTEGER DEFAULT 1, - sort_order INTEGER DEFAULT 0, - created TEXT DEFAULT (datetime('now')) - ); - - -- Legacy tables (kept for migration) - CREATE TABLE IF NOT EXISTS profile ( - id INTEGER PRIMARY KEY, name TEXT, sex TEXT, dob TEXT, - height REAL, updated TEXT - ); - CREATE TABLE IF NOT EXISTS measurements ( - id TEXT PRIMARY KEY, date TEXT, weight REAL, - c_neck REAL, c_chest REAL, c_waist REAL, c_belly REAL, - c_hip REAL, c_thigh REAL, c_calf REAL, c_arm REAL, - sf_method TEXT, sf_chest REAL, sf_axilla REAL, sf_triceps REAL, - sf_subscap REAL, sf_suprailiac REAL, sf_abdomen REAL, - sf_thigh REAL, sf_calf_med REAL, sf_lowerback REAL, sf_biceps REAL, - body_fat_pct REAL, lean_mass REAL, fat_mass REAL, - notes TEXT, photo_id TEXT, created TEXT - ); - """) - conn.commit() - _safe_alters(conn) - _migrate(conn) - _seed_pipeline_prompts(conn) - -def _safe_alters(conn): - """Add missing columns to existing tables safely.""" - alters = [ - ("weight_log", "profile_id TEXT"), - ("weight_log", "source TEXT DEFAULT 'manual'"), - ("circumference_log","profile_id TEXT"), - ("caliper_log", "profile_id TEXT"), - ("nutrition_log", "profile_id TEXT"), - ("activity_log", "profile_id TEXT"), - ("photos", "profile_id TEXT"), - ("photos", "date TEXT"), - ("ai_insights", "profile_id TEXT"), - ("profiles", "goal_weight REAL"), - ("profiles", "goal_bf_pct REAL"), - ("profiles", "role TEXT DEFAULT 'user'"), - ("profiles", "pin_hash TEXT"), - ("profiles", "auth_type TEXT DEFAULT 'pin'"), - ("profiles", "session_days INTEGER DEFAULT 30"), - ("profiles", "ai_enabled INTEGER DEFAULT 1"), - ("profiles", "ai_limit_day INTEGER"), - ("profiles", "export_enabled INTEGER DEFAULT 1"), - ("profiles", "email TEXT"), - ] - for table, col_def in alters: - try: conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}"); conn.commit() - except: pass - -def _migrate(conn): - """Migrate old single-user data → first profile.""" - # Ensure default profile exists - existing = conn.execute("SELECT id FROM profiles LIMIT 1").fetchone() - if existing: - default_pid = existing['id'] - else: - # Try to get name from legacy profile table - legacy = conn.execute("SELECT * FROM profile WHERE id=1").fetchone() - default_pid = str(uuid.uuid4()) - name = legacy['name'] if legacy and legacy['name'] else 'Lars' - sex = legacy['sex'] if legacy else 'm' - dob = legacy['dob'] if legacy else None - height = legacy['height'] if legacy else 178 - conn.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,created,updated) - VALUES (?,?,?,?,?,?,datetime('now'),datetime('now'))""", - (default_pid, name, AVATAR_COLORS[0], sex, dob, height)) - conn.commit() - print(f"Created default profile: {name} ({default_pid})") - - # Migrate legacy weight_log (no profile_id) - orphans = conn.execute("SELECT * FROM weight_log WHERE profile_id IS NULL").fetchall() - for r in orphans: - conn.execute("UPDATE weight_log SET profile_id=? WHERE id=?", (default_pid, r['id'])) - - # Migrate legacy circumference_log - orphans = conn.execute("SELECT * FROM circumference_log WHERE profile_id IS NULL").fetchall() - for r in orphans: - conn.execute("UPDATE circumference_log SET profile_id=? WHERE id=?", (default_pid, r['id'])) - - # Migrate legacy caliper_log - orphans = conn.execute("SELECT * FROM caliper_log WHERE profile_id IS NULL").fetchall() - for r in orphans: - conn.execute("UPDATE caliper_log SET profile_id=? WHERE id=?", (default_pid, r['id'])) - - # Migrate legacy nutrition_log - orphans = conn.execute("SELECT * FROM nutrition_log WHERE profile_id IS NULL").fetchall() - for r in orphans: - conn.execute("UPDATE nutrition_log SET profile_id=? WHERE id=?", (default_pid, r['id'])) - - # Migrate legacy activity_log - orphans = conn.execute("SELECT * FROM activity_log WHERE profile_id IS NULL").fetchall() - for r in orphans: - conn.execute("UPDATE activity_log SET profile_id=? WHERE id=?", (default_pid, r['id'])) - - # Migrate legacy ai_insights - orphans = conn.execute("SELECT * FROM ai_insights WHERE profile_id IS NULL").fetchall() - for r in orphans: - conn.execute("UPDATE ai_insights SET profile_id=? WHERE id=?", (default_pid, r['id'])) - - # Migrate legacy measurements table - meas = conn.execute("SELECT * FROM measurements").fetchall() - for r in meas: - d = dict(r) - date = d.get('date','') - if not date: continue - if d.get('weight'): - if not conn.execute("SELECT id FROM weight_log WHERE profile_id=? AND date=?", (default_pid,date)).fetchone(): - conn.execute("INSERT OR IGNORE INTO weight_log (id,profile_id,date,weight,source,created) VALUES (?,?,?,?,'migrated',datetime('now'))", - (str(uuid.uuid4()), default_pid, date, d['weight'])) - circ_keys = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm'] - if any(d.get(k) for k in circ_keys): - if not conn.execute("SELECT id FROM circumference_log WHERE profile_id=? AND date=?", (default_pid,date)).fetchone(): - conn.execute("""INSERT OR IGNORE 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))""", - (str(uuid.uuid4()),default_pid,date,d.get('c_neck'),d.get('c_chest'),d.get('c_waist'), - d.get('c_belly'),d.get('c_hip'),d.get('c_thigh'),d.get('c_calf'),d.get('c_arm'), - d.get('notes'),d.get('photo_id'))) - sf_keys = ['sf_chest','sf_axilla','sf_triceps','sf_subscap','sf_suprailiac', - 'sf_abdomen','sf_thigh','sf_calf_med','sf_lowerback','sf_biceps'] - if any(d.get(k) for k in sf_keys) or d.get('body_fat_pct'): - if not conn.execute("SELECT id FROM caliper_log WHERE profile_id=? AND date=?", (default_pid,date)).fetchone(): - conn.execute("""INSERT OR IGNORE 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))""", - (str(uuid.uuid4()),default_pid,date,d.get('sf_method','jackson3'), - d.get('sf_chest'),d.get('sf_axilla'),d.get('sf_triceps'),d.get('sf_subscap'), - d.get('sf_suprailiac'),d.get('sf_abdomen'),d.get('sf_thigh'),d.get('sf_calf_med'), - d.get('sf_lowerback'),d.get('sf_biceps'),d.get('body_fat_pct'),d.get('lean_mass'), - d.get('fat_mass'),d.get('notes'))) - conn.commit() - # Ensure first profile is admin - first = conn.execute("SELECT id FROM profiles ORDER BY created LIMIT 1").fetchone() - if first: - conn.execute("UPDATE profiles SET role='admin', ai_enabled=1, export_enabled=1 WHERE id=?", (first['id'],)) - conn.commit() - print("Migration complete") - _seed_prompts(conn) - -def _seed_prompts(conn): - """Insert default prompts if table is empty.""" - count = conn.execute("SELECT COUNT(*) FROM ai_prompts").fetchone()[0] - if count > 0: - return - defaults = [ - ("Gesamtanalyse", "gesamt", "Vollständige Analyse aller verfügbaren Daten", - """Du bist ein Gesundheits- und Ernährungsanalyst. Erstelle eine strukturierte Analyse auf Deutsch (400-500 Wörter). - -PROFIL: {{name}} · {{geschlecht}} · {{height}} cm -Ziele: Gewicht {{goal_weight}} kg · KF {{goal_bf_pct}}% - -GEWICHT: {{weight_trend}} -CALIPER: {{caliper_summary}} -UMFÄNGE: {{circ_summary}} -ERNÄHRUNG: {{nutrition_summary}} -AKTIVITÄT: {{activity_summary}} - -Struktur (alle Abschnitte vollständig ausschreiben): -⚖️ **Gewichts- & Körperzusammensetzung** -🍽️ **Ernährungsanalyse** -🏋️ **Aktivität & Energiebilanz** -🎯 **Zielabgleich** -💪 **Empfehlungen** (3 konkrete Punkte) - -Sachlich, motivierend, Zahlen zitieren, keine Diagnosen.""", 1, 0), - - ("Körperkomposition", "koerper", "Fokus auf Gewicht, Körperfett und Magermasse", - """Analysiere ausschließlich die Körperzusammensetzung auf Deutsch (200-250 Wörter). - -PROFIL: {{name}} · {{geschlecht}} · {{height}} cm · Ziel-KF: {{goal_bf_pct}}% -GEWICHT: {{weight_trend}} -CALIPER: {{caliper_summary}} -UMFÄNGE: {{circ_summary}} - -Abschnitte: -⚖️ **Gewichtstrend** – Entwicklung und Bewertung -🫧 **Körperfett** – Kategorie, Trend, Abstand zum Ziel -💪 **Magermasse** – Erhalt oder Aufbau? -📏 **Umfänge** – Relevante Veränderungen - -Präzise, zahlenbasiert, keine Diagnosen.""", 1, 1), - - ("Ernährung & Kalorien", "ernaehrung", "Fokus auf Kalorienbilanz und Makronährstoffe", - """Analysiere die Ernährungsdaten auf Deutsch (200-250 Wörter). - -PROFIL: {{name}} · {{geschlecht}} · {{height}} cm · Gewicht: {{weight_aktuell}} kg -ERNÄHRUNG: {{nutrition_detail}} -Protein-Ziel: {{protein_ziel_low}}–{{protein_ziel_high}}g/Tag -AKTIVITÄT (Kalorienverbrauch): {{activity_kcal_summary}} - -Abschnitte: -🍽️ **Kalorienbilanz** – Aufnahme vs. Verbrauch, Defizit/Überschuss -🥩 **Proteinversorgung** – Ist vs. Soll, Konsequenzen -📊 **Makroverteilung** – Bewertung Fett/KH/Protein -📅 **Muster** – Regelmäßigkeit, Schwankungen - -Zahlenbasiert, konkret, keine Diagnosen.""", 1, 2), - - ("Aktivität & Training", "aktivitaet", "Fokus auf Trainingsvolumen und Energieverbrauch", - """Analysiere die Aktivitätsdaten auf Deutsch (200-250 Wörter). - -PROFIL: {{name}} · {{geschlecht}} -AKTIVITÄT: {{activity_detail}} -GEWICHT: {{weight_trend}} - -Abschnitte: -🏋️ **Trainingsvolumen** – Häufigkeit, Dauer, Typen -🔥 **Energieverbrauch** – Aktive Kalorien, Durchschnitt -❤️ **Intensität** – Herzfrequenz-Analyse -📈 **Trend** – Trainingsregelmäßigkeit -💡 **Empfehlung** – 1-2 konkrete Punkte - -Motivierend, zahlenbasiert, keine Diagnosen.""", 1, 3), - - ("Gesundheitsindikatoren", "gesundheit", "WHR, WHtR, BMI und weitere Kennzahlen", - """Berechne und bewerte die Gesundheitsindikatoren auf Deutsch (200-250 Wörter). - -PROFIL: {{name}} · {{geschlecht}} · {{height}} cm -GEWICHT: {{weight_aktuell}} kg -UMFÄNGE: {{circ_summary}} -CALIPER: {{caliper_summary}} - -Berechne und bewerte: -📐 **WHR** (Taille/Hüfte) – Ziel: <0,90 M / <0,85 F -📏 **WHtR** (Taille/Größe) – Ziel: <0,50 -⚖️ **BMI** – Einordnung mit Kontext -💪 **FFMI** – Muskelmasse-Index (falls KF-Daten vorhanden) -🎯 **Gesamtbewertung** – Ampel-System (grün/gelb/rot) - -Sachlich, evidenzbasiert, keine Diagnosen.""", 1, 4), - - ("Fortschritt zu Zielen", "ziele", "Wie weit bin ich von meinen Zielen entfernt?", - """Bewerte den Fortschritt zu den gesetzten Zielen auf Deutsch (200-250 Wörter). - -PROFIL: {{name}} -Ziel-Gewicht: {{goal_weight}} kg · Ziel-KF: {{goal_bf_pct}}% -AKTUELL: Gewicht {{weight_aktuell}} kg · KF {{kf_aktuell}}% -TREND: {{weight_trend}} - -Abschnitte: -🎯 **Zielerreichung** – Abstand zu Gewichts- und KF-Ziel -📈 **Tempo** – Hochrechnung: Wann wird das Ziel erreicht? -✅ **Was läuft gut** – Positive Entwicklungen -⚠️ **Was bremst** – Hindernisse -🗺️ **Nächste Schritte** – 2-3 konkrete Maßnahmen - -Realistisch, motivierend, zahlenbasiert.""", 1, 5), - ] - for name, slug, desc, template, active, sort in defaults: - conn.execute( - "INSERT OR IGNORE INTO ai_prompts (id,name,slug,description,template,active,sort_order,created) VALUES (?,?,?,?,?,?,?,datetime('now'))", - (str(__import__('uuid').uuid4()), name, slug, desc, template, active, sort) - ) - conn.commit() - print(f"Seeded {len(defaults)} default prompts") - -def _seed_pipeline_prompts(conn): - """Seed pipeline stage prompts if not present.""" - pipeline_defaults = [ - ("Pipeline: Körper-Analyse (JSON)", "pipeline_body", - "⚠️ JSON-Output – Stufe 1 der mehrstufigen Analyse. Format muss erhalten bleiben!", - """Analysiere diese Körperdaten und gib NUR ein JSON-Objekt zurück (kein Text drumherum). -Profil: {{name}} {{geschlecht}} {{height}}cm {{age}}J -Gewicht: {{weight_trend}} -Caliper: {{caliper_summary}} -Umfänge: {{circ_summary}} -Ziele: Gewicht {{goal_weight}}kg KF {{goal_bf_pct}}% - -Pflichtformat: -{"gewicht_trend": "sinkend|steigend|stabil", - "gewicht_delta_30d": , - "kf_aktuell": , - "kf_trend": "sinkend|steigend|stabil|unbekannt", - "magermasse_delta": , - "whr_status": "gut|grenzwertig|erhoeht|unbekannt", - "whtr_status": "optimal|gut|erhoeht|unbekannt", - "koerper_bewertung": "<1 Satz>", - "koerper_auffaelligkeiten": "<1 Satz oder null>"}""", 1, 10), - - ("Pipeline: Ernährungs-Analyse (JSON)", "pipeline_nutrition", - "⚠️ JSON-Output – Stufe 1 der mehrstufigen Analyse. Format muss erhalten bleiben!", - """Analysiere diese Ernährungsdaten und gib NUR ein JSON-Objekt zurück. -Ø {{kcal_avg}}kcal Ø {{protein_avg}}g Protein Ø {{fat_avg}}g Fett Ø {{carb_avg}}g KH ({{nutrition_days}} Tage) -Protein-Ziel: {{protein_ziel_low}}–{{protein_ziel_high}}g/Tag -Körpergewicht: {{weight_aktuell}}kg - -Pflichtformat: -{"kcal_avg": , - "protein_avg": , - "protein_ziel_erreicht": , - "protein_defizit_g": , - "kalorienbilanz": "defizit|ausgeglichen|ueberschuss", - "makro_bewertung": "gut|ausgewogen|proteinarm|kohlenhydratlastig|fettlastig", - "ernaehrung_bewertung": "<1 Satz>", - "ernaehrung_empfehlung": "<1 konkreter Tipp>"}""", 1, 11), - - ("Pipeline: Aktivitäts-Analyse (JSON)", "pipeline_activity", - "⚠️ JSON-Output – Stufe 1 der mehrstufigen Analyse. Format muss erhalten bleiben!", - """Analysiere diese Aktivitätsdaten und gib NUR ein JSON-Objekt zurück. -{{activity_detail}} - -Pflichtformat: -{"trainings_anzahl": , - "kcal_gesamt": , - "konsistenz": "hoch|mittel|niedrig", - "haupttrainingsart": "", - "aktivitaet_bewertung": "<1 Satz>", - "aktivitaet_empfehlung": "<1 konkreter Tipp>"}""", 1, 12), - - ("Pipeline: Synthese (Gesamtanalyse)", "pipeline_synthesis", - "Stufe 2 – Narrative Gesamtanalyse aus den JSON-Summaries der Stufe 1", - """Du bist ein Gesundheits- und Fitnesscoach. Erstelle eine vollständige, -personalisierte Analyse für {{name}} auf Deutsch (450–550 Wörter). - -DATENZUSAMMENFASSUNGEN AUS STUFE 1: -Körper: {{stage1_body}} -Ernährung: {{stage1_nutrition}} -Aktivität: {{stage1_activity}} -Protein-Ziel: {{protein_ziel_low}}–{{protein_ziel_high}}g/Tag - -Schreibe alle Abschnitte vollständig aus: -⚖️ **Gewichts- & Körperzusammensetzung** -🍽️ **Ernährungsanalyse** -🏋️ **Aktivität & Energiebilanz** -🔗 **Zusammenhänge** (Verbindungen zwischen Ernährung, Training, Körper) -💪 **3 Empfehlungen** (nummeriert, konkret, datenbasiert) - -Sachlich, motivierend, Zahlen zitieren, keine Diagnosen.""", 1, 13), - - ("Pipeline: Zielabgleich", "pipeline_goals", - "Stufe 3 – Fortschrittsbewertung zu gesetzten Zielen (nur wenn Ziele definiert)", - """Kurze Ziel-Bewertung für {{name}} (100–150 Wörter, Deutsch): -Ziel-Gewicht: {{goal_weight}}kg | Ziel-KF: {{goal_bf_pct}}% -Körper-Summary: {{stage1_body}} - -🎯 **Zielfortschritt** -Abstand zu den Zielen, realistisches Zeitfenster, 1–2 nächste konkrete Schritte.""", 1, 14), - ] - for name, slug, desc, template, active, sort in pipeline_defaults: - conn.execute( - "INSERT OR IGNORE INTO ai_prompts (id,name,slug,description,template,active,sort_order,created) VALUES (?,?,?,?,?,?,?,datetime('now'))", - (str(__import__('uuid').uuid4()), name, slug, desc, template, active, sort) - ) - conn.commit() - print(f"Seeded {len(pipeline_defaults)} pipeline prompts") - -init_db() + """Initialize database - Schema is loaded by startup.sh""" + # Schema loading and migration handled by startup.sh + # This function kept for backwards compatibility + pass # ── Helper: get profile_id from header ─────────────────────────────────────── def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: @@ -507,7 +51,9 @@ def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: if x_profile_id: return x_profile_id with get_db() as conn: - row = conn.execute("SELECT id FROM profiles ORDER BY created LIMIT 1").fetchone() + cur = conn.cursor() + 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") @@ -588,11 +134,13 @@ def make_token() -> str: def get_session(token: str): if not token: return None with get_db() as conn: - row = conn.execute( + cur = conn.cursor() + cur.execute( "SELECT s.*, p.role, p.name, p.ai_enabled, p.ai_limit_day, p.export_enabled " "FROM sessions s JOIN profiles p ON s.profile_id=p.id " - "WHERE s.token=? AND s.expires_at > datetime('now')", (token,) - ).fetchone() + "WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP", (token,) + ) + row = cur.fetchone() return r2d(row) def require_auth(x_auth_token: Optional[str]=Header(default=None)): @@ -609,24 +157,30 @@ def require_admin(x_auth_token: Optional[str]=Header(default=None)): @app.get("/api/profiles") def list_profiles(session=Depends(require_auth)): with get_db() as conn: - rows = conn.execute("SELECT * FROM profiles ORDER BY created").fetchall() + cur = conn.cursor() + 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: - conn.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated) - VALUES (?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))""", + cur = conn.cursor() + 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)) - conn.commit() with get_db() as conn: - return r2d(conn.execute("SELECT * FROM profiles WHERE id=?", (pid,)).fetchone()) + cur = conn.cursor() + 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: - row = conn.execute("SELECT * FROM profiles WHERE id=?", (pid,)).fetchone() + cur = conn.cursor() + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + row = cur.fetchone() if not row: raise HTTPException(404, "Profil nicht gefunden") return r2d(row) @@ -635,76 +189,84 @@ 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() - conn.execute(f"UPDATE profiles SET {', '.join(f'{k}=?' for k in data)} WHERE id=?", + cur = conn.cursor() + cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s", list(data.values())+[pid]) - conn.commit() - return get_profile(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: - count = conn.execute("SELECT COUNT(*) FROM profiles").fetchone()[0] + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM profiles") + count = cur.fetchone()[0] 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']: - conn.execute(f"DELETE FROM {table} WHERE profile_id=?", (pid,)) - conn.execute("DELETE FROM profiles WHERE id=?", (pid,)) - conn.commit() + 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) + 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) + 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: - return [r2d(r) for r in conn.execute( - "SELECT * FROM weight_log WHERE profile_id=? ORDER BY date DESC LIMIT ?", (pid,limit)).fetchall()] + cur = conn.cursor() + 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: - ex = conn.execute("SELECT id FROM weight_log WHERE profile_id=? AND date=?", (pid,e.date)).fetchone() + cur = conn.cursor() + cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date)) + ex = cur.fetchone() if ex: - conn.execute("UPDATE weight_log SET weight=?,note=? WHERE id=?", (e.weight,e.note,ex['id'])) + 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()) - conn.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (?,?,?,?,?,datetime('now'))", + 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)) - conn.commit() 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: - conn.execute("UPDATE weight_log SET date=?,weight=?,note=? WHERE id=? AND profile_id=?", - (e.date,e.weight,e.note,wid,pid)); conn.commit() + cur = conn.cursor() + 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: - conn.execute("DELETE FROM weight_log WHERE id=? AND profile_id=?", (wid,pid)); conn.commit() + cur = conn.cursor() + 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: - rows = conn.execute("SELECT date,weight FROM weight_log WHERE profile_id=? ORDER BY date DESC LIMIT 90", (pid,)).fetchall() + cur = conn.cursor() + 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=[r['weight'] for r in rows] return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":rows[0]['weight']}, @@ -716,28 +278,31 @@ def weight_stats(x_profile_id: Optional[str]=Header(default=None), session: dict 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: - return [r2d(r) for r in conn.execute( - "SELECT * FROM circumference_log WHERE profile_id=? ORDER BY date DESC LIMIT ?", (pid,limit)).fetchall()] + cur = conn.cursor() + 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: - ex = conn.execute("SELECT id FROM circumference_log WHERE profile_id=? AND date=?", (pid,e.date)).fetchone() + cur = conn.cursor() + 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}=?" for k in d if k!='date') - conn.execute(f"UPDATE circumference_log SET {sets} WHERE 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()) - conn.execute("""INSERT INTO circumference_log + 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))""", + 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'])) - conn.commit() return {"id":eid,"date":e.date} @app.put("/api/circumferences/{eid}") @@ -745,15 +310,17 @@ def update_circ(eid: str, e: CircumferenceEntry, x_profile_id: Optional[str]=Hea pid = get_pid(x_profile_id) with get_db() as conn: d = e.model_dump() - conn.execute(f"UPDATE circumference_log SET {', '.join(f'{k}=?' for k in d)} WHERE id=? AND profile_id=?", - list(d.values())+[eid,pid]); conn.commit() + cur = conn.cursor() + 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: - conn.execute("DELETE FROM circumference_log WHERE id=? AND profile_id=?", (eid,pid)); conn.commit() + cur = conn.cursor() + cur.execute("DELETE FROM circumference_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} # ── Caliper ─────────────────────────────────────────────────────────────────── @@ -761,30 +328,33 @@ def delete_circ(eid: str, x_profile_id: Optional[str]=Header(default=None), sess 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: - return [r2d(r) for r in conn.execute( - "SELECT * FROM caliper_log WHERE profile_id=? ORDER BY date DESC LIMIT ?", (pid,limit)).fetchall()] + cur = conn.cursor() + 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: - ex = conn.execute("SELECT id FROM caliper_log WHERE profile_id=? AND date=?", (pid,e.date)).fetchone() + cur = conn.cursor() + 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}=?" for k in d if k!='date') - conn.execute(f"UPDATE caliper_log SET {sets} WHERE 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()) - conn.execute("""INSERT INTO caliper_log + 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))""", + 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'])) - conn.commit() return {"id":eid,"date":e.date} @app.put("/api/caliper/{eid}") @@ -792,15 +362,17 @@ def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header pid = get_pid(x_profile_id) with get_db() as conn: d = e.model_dump() - conn.execute(f"UPDATE caliper_log SET {', '.join(f'{k}=?' for k in d)} WHERE id=? AND profile_id=?", - list(d.values())+[eid,pid]); conn.commit() + cur = conn.cursor() + 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: - conn.execute("DELETE FROM caliper_log WHERE id=? AND profile_id=?", (eid,pid)); conn.commit() + cur = conn.cursor() + cur.execute("DELETE FROM caliper_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} # ── Activity ────────────────────────────────────────────────────────────────── @@ -808,8 +380,10 @@ def delete_caliper(eid: str, x_profile_id: Optional[str]=Header(default=None), s 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: - return [r2d(r) for r in conn.execute( - "SELECT * FROM activity_log WHERE profile_id=? ORDER BY date DESC, start_time DESC LIMIT ?", (pid,limit)).fetchall()] + cur = conn.cursor() + 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)): @@ -817,14 +391,14 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default eid = str(uuid.uuid4()) d = e.model_dump() with get_db() as conn: - conn.execute("""INSERT INTO activity_log + cur = conn.cursor() + 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))""", + 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'])) - conn.commit() return {"id":eid,"date":e.date} @app.put("/api/activity/{eid}") @@ -832,23 +406,27 @@ def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Head pid = get_pid(x_profile_id) with get_db() as conn: d = e.model_dump() - conn.execute(f"UPDATE activity_log SET {', '.join(f'{k}=?' for k in d)} WHERE id=? AND profile_id=?", - list(d.values())+[eid,pid]); conn.commit() + cur = conn.cursor() + 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: - conn.execute("DELETE FROM activity_log WHERE id=? AND profile_id=?", (eid,pid)); conn.commit() + cur = conn.cursor() + 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: - rows = [r2d(r) for r in conn.execute( - "SELECT * FROM activity_log WHERE profile_id=? ORDER BY date DESC LIMIT 30", (pid,)).fetchall()] + cur = conn.cursor() + 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(r.get('kcal_active') or 0 for r in rows) total_min=sum(r.get('duration_min') or 0 for r in rows) @@ -860,7 +438,7 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di 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)): +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') @@ -870,6 +448,7 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional reader = csv.DictReader(io.StringIO(text)) inserted = skipped = 0 with get_db() as conn: + cur = conn.cursor() for row in reader: wtype = row.get('Workout Type','').strip() start = row.get('Start','').strip() @@ -890,10 +469,10 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional try: return round(float(v),1) if v else None except: return None try: - conn.execute("""INSERT INTO activity_log + 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 (?,?,?,?,?,?,?,?,?,?,?,?,'apple_health',datetime('now'))""", + 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)','')), @@ -901,36 +480,40 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional tf(row.get('Distanz (km)','')))) inserted+=1 except: skipped+=1 - conn.commit() 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)): + 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: - conn.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (?,?,?,?,datetime('now'))", - (fid,pid,date,str(path))); conn.commit() + cur = conn.cursor() + 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): +def get_photo(fid: str, session: dict=Depends(require_auth)): with get_db() as conn: - row = conn.execute("SELECT path FROM photos WHERE id=?", (fid,)).fetchone() + cur = conn.cursor() + cur.execute("SELECT path FROM photos WHERE id=%s", (fid,)) + row = cur.fetchone() if not row: raise HTTPException(404) return FileResponse(row['path']) @app.get("/api/photos") -def list_photos(x_profile_id: Optional[str]=Header(default=None)): +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: - return [r2d(r) for r in conn.execute( - "SELECT * FROM photos WHERE profile_id=? ORDER BY created DESC LIMIT 100", (pid,)).fetchall()] + cur = conn.cursor() + 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): @@ -938,7 +521,7 @@ def _pf(s): 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)): +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') @@ -963,17 +546,18 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona count+=1 inserted=0 with get_db() as conn: + cur = conn.cursor() 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) - if conn.execute("SELECT id FROM nutrition_log WHERE profile_id=? AND date=?",(pid,iso)).fetchone(): - conn.execute("UPDATE nutrition_log SET kcal=?,protein_g=?,fat_g=?,carbs_g=? WHERE profile_id=? AND date=?", + 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: - conn.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (?,?,?,?,?,?,?,'csv',datetime('now'))", + 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 - conn.commit() return {"rows_parsed":count,"days_imported":inserted, "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} @@ -981,16 +565,22 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona 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: - return [r2d(r) for r in conn.execute( - "SELECT * FROM nutrition_log WHERE profile_id=? ORDER BY date DESC LIMIT ?", (pid,limit)).fetchall()] + cur = conn.cursor() + 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: - nutr={r['date']:r2d(r) for r in conn.execute("SELECT * FROM nutrition_log WHERE profile_id=? ORDER BY date",(pid,)).fetchall()} - wlog={r['date']:r['weight'] for r in conn.execute("SELECT date,weight FROM weight_log WHERE profile_id=? ORDER BY date",(pid,)).fetchall()} - cals=sorted([r2d(r) for r in conn.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=? ORDER BY date",(pid,)).fetchall()],key=lambda x:x['date']) + cur = conn.cursor() + 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: @@ -1012,7 +602,9 @@ def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), ses def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - rows=[r2d(r) for r in conn.execute("SELECT * FROM nutrition_log WHERE profile_id=? ORDER BY date DESC LIMIT ?",(pid,weeks*7)).fetchall()] + cur = conn.cursor() + cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s",(pid,weeks*7)) + rows=[r2d(r) for r in cur.fetchall()] if not rows: return [] wm={} for d in rows: @@ -1030,1048 +622,695 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N def get_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 = conn.cursor() + cur.execute("SELECT COUNT(*) FROM weight_log WHERE profile_id=%s",(pid,)) + weight_count = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM circumference_log WHERE profile_id=%s",(pid,)) + circ_count = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM caliper_log WHERE profile_id=%s",(pid,)) + caliper_count = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM nutrition_log WHERE profile_id=%s",(pid,)) + nutrition_count = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM activity_log WHERE profile_id=%s",(pid,)) + activity_count = cur.fetchone()[0] return { - "weight_count": conn.execute("SELECT COUNT(*) FROM weight_log WHERE profile_id=?",(pid,)).fetchone()[0], - "circ_count": conn.execute("SELECT COUNT(*) FROM circumference_log WHERE profile_id=?",(pid,)).fetchone()[0], - "caliper_count": conn.execute("SELECT COUNT(*) FROM caliper_log WHERE profile_id=?",(pid,)).fetchone()[0], - "latest_weight": r2d(conn.execute("SELECT * FROM weight_log WHERE profile_id=? ORDER BY date DESC LIMIT 1",(pid,)).fetchone()), - "latest_circ": r2d(conn.execute("SELECT * FROM circumference_log WHERE profile_id=? ORDER BY date DESC LIMIT 1",(pid,)).fetchone()), - "latest_caliper":r2d(conn.execute("SELECT * FROM caliper_log WHERE profile_id=? ORDER BY date DESC LIMIT 1",(pid,)).fetchone()), + "weight_count": weight_count, + "circ_count": circ_count, + "caliper_count": caliper_count, + "nutrition_count": nutrition_count, + "activity_count": activity_count } -# ── AI ──────────────────────────────────────────────────────────────────────── -@app.post("/api/insights/trend") -def insight_trend(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - pid = get_pid(x_profile_id) - try: - with get_db() as conn: - profile = r2d(conn.execute("SELECT * FROM profiles WHERE id=?",(pid,)).fetchone()) - weights = [r2d(r) for r in conn.execute("SELECT * FROM weight_log WHERE profile_id=? ORDER BY date DESC LIMIT 14",(pid,)).fetchall()] - calipers = [r2d(r) for r in conn.execute("SELECT * FROM caliper_log WHERE profile_id=? ORDER BY date DESC LIMIT 5",(pid,)).fetchall()] - circs = [r2d(r) for r in conn.execute("SELECT * FROM circumference_log WHERE profile_id=? ORDER BY date DESC LIMIT 5",(pid,)).fetchall()] - nutrition = [r2d(r) for r in conn.execute("SELECT * FROM nutrition_log WHERE profile_id=? ORDER BY date DESC LIMIT 14",(pid,)).fetchall()] - activities= [r2d(r) for r in conn.execute("SELECT * FROM activity_log WHERE profile_id=? ORDER BY date DESC LIMIT 20",(pid,)).fetchall()] +# ── AI Insights ─────────────────────────────────────────────────────────────── +import httpx, json - if nutrition: - avg_kcal=round(sum(n['kcal'] or 0 for n in nutrition)/len(nutrition)) - avg_prot=round(sum(n['protein_g'] or 0 for n in nutrition)/len(nutrition),1) - avg_fat =round(sum(n['fat_g'] or 0 for n in nutrition)/len(nutrition),1) - avg_carbs=round(sum(n['carbs_g'] or 0 for n in nutrition)/len(nutrition),1) - nutr_summary=f"{len(nutrition)} Tage · Ø {avg_kcal} kcal · Ø {avg_prot}g Protein · Ø {avg_fat}g Fett · Ø {avg_carbs}g KH" - nutr_detail=[{"date":n['date'],"kcal":round(n['kcal'] or 0),"protein_g":n['protein_g'],"fat_g":n['fat_g'],"carbs_g":n['carbs_g']} for n in nutrition] +@app.get("/api/ai/insights/{scope}") +def get_ai_insight(scope: 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 = conn.cursor() + 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) + +@app.delete("/api/ai/insights/{scope}") +def delete_ai_insight(scope: 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 = conn.cursor() + cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid,scope)) + return {"ok":True} + +def check_ai_limit(pid: str): + """Check if profile has reached daily AI limit. Returns (allowed, limit, used).""" + with get_db() as conn: + cur = conn.cursor() + 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 = conn.cursor() + 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: - nutr_summary="Keine Ernährungsdaten"; nutr_detail=[] + cur.execute("INSERT INTO ai_usage (id, profile_id, date, call_count) VALUES (%s,%s,%s,1)", + (str(uuid.uuid4()), pid, today)) - latest_w = weights[0]['weight'] if weights else None - pt_low = round(latest_w*1.6,0) if latest_w else None - pt_high = round(latest_w*2.2,0) if latest_w else None - - if activities: - total_kcal_act=round(sum(a.get('kcal_active') or 0 for a in activities)) - total_min_act=round(sum(a.get('duration_min') or 0 for a in activities)) - types={}; - for a in activities: t=a['activity_type']; types.setdefault(t,0); types[t]+=1 - act_summary=f"{len(activities)} Trainings · {total_kcal_act} kcal gesamt · {total_min_act} Min · {types}\n" - act_summary+=str([{"date":a['date'],"type":a['activity_type'],"min":a.get('duration_min'),"kcal":a.get('kcal_active'),"hr_avg":round(a['hr_avg']) if a.get('hr_avg') else None} for a in activities]) - else: - act_summary="Keine Aktivitätsdaten" - - # Build compact weight summary - w_summary = "" - if weights: - w_first = weights[-1]; w_last = weights[0] - w_diff = round(w_last['weight'] - w_first['weight'], 1) - w_summary = f"{w_first['date']}: {w_first['weight']}kg → {w_last['date']}: {w_last['weight']}kg (Δ{w_diff:+.1f}kg)" - - # Build compact caliper summary - ca_summary = "" - if calipers: - ca = calipers[0] - ca_summary = f"KF: {ca.get('body_fat_pct')}% · Mager: {ca.get('lean_mass')}kg · Fett: {ca.get('fat_mass')}kg ({ca.get('date')})" - if len(calipers) > 1: - ca_prev = calipers[1] - ca_summary += f" | Vorher: {ca_prev.get('body_fat_pct')}% ({ca_prev.get('date')})" - - # Build compact circ summary - ci_summary = "" - if circs: - c = circs[0] - ci_summary = f"Taille: {c.get('c_waist')} · Hüfte: {c.get('c_hip')} · Bauch: {c.get('c_belly')} · Brust: {c.get('c_chest')} cm ({c.get('date')})" - - prompt = f"""Du bist ein Gesundheits- und Ernährungsanalyst. Erstelle eine strukturierte Analyse auf Deutsch (400-500 Wörter). - -PROFIL: {profile.get('name')} · {'männlich' if profile.get('sex')=='m' else 'weiblich'} · {profile.get('height')} cm -Ziele: Gewicht {profile.get('goal_weight','–')} kg · KF {profile.get('goal_bf_pct','–')}% - -GEWICHT: {w_summary} -CALIPER: {ca_summary} -UMFÄNGE: {ci_summary} - -ERNÄHRUNG ({nutr_summary}): -{nutr_detail} -Protein-Ziel: {pt_low}–{pt_high}g/Tag - -AKTIVITÄT: {act_summary} - -Struktur (jeden Abschnitt vollständig ausschreiben): -⚖️ **Gewichts- & Körperzusammensetzung** -🍽️ **Ernährungsanalyse** -🏋️ **Aktivität & Energiebilanz** -🎯 **Zielabgleich** -💪 **Empfehlungen** (3 konkrete Punkte) - -Sachlich, motivierend, Zahlen zitieren, keine Diagnosen. Alle 5 Abschnitte vollständig ausschreiben.""" - - if OPENROUTER_KEY: - import httpx - resp=httpx.post("https://openrouter.ai/api/v1/chat/completions", - headers={"Authorization":f"Bearer {OPENROUTER_KEY}"}, - json={"model":OPENROUTER_MODEL,"messages":[{"role":"user","content":prompt}],"max_tokens":2500}) - text=resp.json()['choices'][0]['message']['content'] - elif ANTHROPIC_KEY: - import anthropic - client=anthropic.Anthropic(api_key=ANTHROPIC_KEY) - msg=client.messages.create(model="claude-sonnet-4-20250514",max_tokens=2500, - messages=[{"role":"user","content":prompt}]) - text=msg.content[0].text - else: - raise HTTPException(400,"Kein API-Key") - - iid=str(uuid.uuid4()) - with get_db() as conn: - conn.execute("INSERT INTO ai_insights (id,profile_id,scope,content,created) VALUES (?,?,?,?,datetime('now'))", - (iid,pid,'trend',text)); conn.commit() - return {"id":iid,"content":text} - except HTTPException: raise - except Exception as e: raise HTTPException(500,f"AI-Fehler: {e}") - -@app.delete("/api/insights/{iid}") -def delete_insight(iid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - pid = get_pid(x_profile_id) +def _get_profile_data(pid: str): + """Fetch all relevant data for AI analysis.""" with get_db() as conn: - conn.execute("DELETE FROM ai_insights WHERE id=? AND profile_id=?", (iid,pid)); conn.commit() - return {"ok": True} - -@app.get("/api/insights/latest") -def latest_insights_by_scope(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Return the most recent insight per scope/slug.""" - pid = get_pid(x_profile_id) - with get_db() as conn: - rows = conn.execute( - """SELECT * FROM ai_insights WHERE profile_id=? - AND id IN ( - SELECT id FROM ai_insights i2 - WHERE i2.profile_id=ai_insights.profile_id - AND i2.scope=ai_insights.scope - ORDER BY created DESC LIMIT 1 - ) - ORDER BY scope""", (pid,) - ).fetchall() - return [r2d(r) for r in rows] - -@app.get("/api/insights") -def list_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - pid = get_pid(x_profile_id) - with get_db() as conn: - return [r2d(r) for r in conn.execute( - "SELECT * FROM ai_insights WHERE profile_id=? ORDER BY created DESC LIMIT 20",(pid,)).fetchall()] - -# ── Export ──────────────────────────────────────────────────────────────────── -import zipfile, json as json_lib - -def _get_export_data(pid: str, conn): - profile = r2d(conn.execute("SELECT * FROM profiles WHERE id=?", (pid,)).fetchone()) or {} - weights = [r2d(r) for r in conn.execute("SELECT date,weight,note,source FROM weight_log WHERE profile_id=? ORDER BY date",(pid,)).fetchall()] - circs = [r2d(r) for r in conn.execute("SELECT date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes FROM circumference_log WHERE profile_id=? ORDER BY date",(pid,)).fetchall()] - calipers = [r2d(r) for r in conn.execute("SELECT date,sf_method,body_fat_pct,lean_mass,fat_mass,notes FROM caliper_log WHERE profile_id=? ORDER BY date",(pid,)).fetchall()] - nutr = [r2d(r) for r in conn.execute("SELECT date,kcal,protein_g,fat_g,carbs_g,source FROM nutrition_log WHERE profile_id=? ORDER BY date",(pid,)).fetchall()] - activity = [r2d(r) for r in conn.execute("SELECT date,activity_type,duration_min,kcal_active,hr_avg,hr_max,distance_km,rpe,source,notes FROM activity_log WHERE profile_id=? ORDER BY date DESC",(pid,)).fetchall()] - insights = [r2d(r) for r in conn.execute("SELECT created,scope,content FROM ai_insights WHERE profile_id=? ORDER BY created DESC",(pid,)).fetchall()] - return profile, weights, circs, calipers, nutr, activity, insights - -def _make_csv(rows, fields=None): - if not rows: return "" - out = io.StringIO() - f = fields or list(rows[0].keys()) - wr = csv.DictWriter(out, fieldnames=f, extrasaction='ignore') - wr.writeheader(); wr.writerows(rows) - return out.getvalue() - -@app.get("/api/export/zip") -def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - pid = get_pid(x_profile_id) - with get_db() as conn: - profile, weights, circs, calipers, nutr, activity, insights = _get_export_data(pid, conn) - - name = profile.get('name','profil').lower().replace(' ','_') - date = datetime.now().strftime('%Y%m%d') - filename = f"bodytrack_{name}_{date}.zip" - - buf = io.BytesIO() - with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: - # Profile JSON - prof_export = {k:v for k,v in profile.items() if k not in ['id','photo_id']} - zf.writestr("profil.json", json_lib.dumps(prof_export, ensure_ascii=False, indent=2)) - # CSVs - if weights: zf.writestr("gewicht.csv", _make_csv(weights)) - if circs: zf.writestr("umfaenge.csv", _make_csv(circs)) - if calipers: zf.writestr("caliper.csv", _make_csv(calipers)) - if nutr: zf.writestr("ernaehrung.csv", _make_csv(nutr)) - if activity: zf.writestr("aktivitaet.csv", _make_csv(activity)) - # KI-Auswertungen als Klartext - if insights: - txt = "" - for ins in insights: - txt += f"{'='*60}\n" - txt += f"Datum: {ins['created'][:16]}\n" - txt += f"{'='*60}\n" - txt += ins['content'] + "\n\n" - zf.writestr("ki_auswertungen.txt", txt) - buf.seek(0) - return StreamingResponse(iter([buf.read()]), media_type="application/zip", - headers={"Content-Disposition": f"attachment; filename={filename}"}) - -@app.get("/api/export/json") -def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - pid = get_pid(x_profile_id) - with get_db() as conn: - profile, weights, circs, calipers, nutr, activity, insights = _get_export_data(pid, conn) - - name = profile.get('name','profil').lower().replace(' ','_') - date = datetime.now().strftime('%Y%m%d') - filename = f"bodytrack_{name}_{date}.json" - - data = { - "export_version": "1.0", - "exported_at": datetime.now().isoformat(), - "profile": {k:v for k,v in profile.items() if k not in ['id','photo_id']}, - "gewicht": weights, - "umfaenge": circs, - "caliper": calipers, - "ernaehrung": nutr, - "aktivitaet": activity, - "ki_auswertungen": [{"datum":i['created'],"inhalt":i['content']} for i in insights], + cur = conn.cursor() + 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 } - return StreamingResponse( - iter([json_lib.dumps(data, ensure_ascii=False, indent=2)]), - media_type="application/json", - headers={"Content-Disposition": f"attachment; filename={filename}"} - ) -@app.get("/api/export/csv") -def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Legacy single-file CSV export.""" - pid = get_pid(x_profile_id) - with get_db() as conn: - profile, weights, circs, calipers, nutr, activity, _ = _get_export_data(pid, conn) - out = io.StringIO() - for label, rows in [("GEWICHT",weights),("UMFAENGE",circs),("CALIPER",calipers),("ERNAEHRUNG",nutr),("AKTIVITAET",activity)]: - out.write(f"=== {label} ===\n") - if rows: out.write(_make_csv(rows)) - out.write("\n") - out.seek(0) - name = profile.get('name','export').lower().replace(' ','_') - return StreamingResponse(iter([out.getvalue()]), media_type="text/csv", - headers={"Content-Disposition": f"attachment; filename=bodytrack_{name}.csv"}) +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 -# ── Routes: AI Prompts ──────────────────────────────────────────────────────── -class PromptUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - template: Optional[str] = None - active: Optional[int] = None - sort_order: Optional[int] = None +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'] -@app.get("/api/prompts") -def list_prompts(session=Depends(require_auth)): - with get_db() as conn: - rows = conn.execute("SELECT * FROM ai_prompts ORDER BY sort_order, name").fetchall() - return [r2d(r) for r in rows] - -@app.put("/api/prompts/{pid}") -def update_prompt(pid: str, p: PromptUpdate, x_auth_token: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - require_admin(x_auth_token) - with get_db() as conn: - data = {k:v for k,v in p.model_dump().items() if v is not None} - if not data: return {"ok": True} - conn.execute(f"UPDATE ai_prompts SET {', '.join(f'{k}=?' for k in data)} WHERE id=?", - list(data.values())+[pid]) - conn.commit() - return {"ok": True} - -@app.post("/api/prompts/{pid}/reset") -def reset_prompt(pid: str, session=Depends(require_auth)): - """Reset prompt to default by re-seeding.""" - with get_db() as conn: - _seed_prompts(conn) - return {"ok": True} - -@app.post("/api/insights/run/{slug}") -def run_insight(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Run a specific prompt by slug.""" - pid = get_pid(x_profile_id) - check_ai_limit(pid) - with get_db() as conn: - prompt_row = conn.execute("SELECT * FROM ai_prompts WHERE slug=?", (slug,)).fetchone() - if not prompt_row: raise HTTPException(404, f"Prompt '{slug}' nicht gefunden") - template = prompt_row['template'] - - profile = r2d(conn.execute("SELECT * FROM profiles WHERE id=?", (pid,)).fetchone()) or {} - weights = [r2d(r) for r in conn.execute("SELECT * FROM weight_log WHERE profile_id=? ORDER BY date DESC LIMIT 14",(pid,)).fetchall()] - calipers = [r2d(r) for r in conn.execute("SELECT * FROM caliper_log WHERE profile_id=? ORDER BY date DESC LIMIT 5",(pid,)).fetchall()] - circs = [r2d(r) for r in conn.execute("SELECT * FROM circumference_log WHERE profile_id=? ORDER BY date DESC LIMIT 5",(pid,)).fetchall()] - nutrition = [r2d(r) for r in conn.execute("SELECT * FROM nutrition_log WHERE profile_id=? ORDER BY date DESC LIMIT 14",(pid,)).fetchall()] - activities= [r2d(r) for r in conn.execute("SELECT * FROM activity_log WHERE profile_id=? ORDER BY date DESC LIMIT 20",(pid,)).fetchall()] - - # Build template variables vars = { - "name": profile.get('name',''), - "geschlecht": 'männlich' if profile.get('sex')=='m' else 'weiblich', - "height": str(profile.get('height','')), - "goal_weight": str(profile.get('goal_weight','–')), - "goal_bf_pct": str(profile.get('goal_bf_pct','–')), - "weight_aktuell": str(weights[0]['weight']) if weights else '–', - "kf_aktuell": str(calipers[0].get('body_fat_pct','–')) if calipers else '–', + "name": prof.get('name', 'Nutzer'), + "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich", + "height": prof.get('height', 178), + "goal_weight": prof.get('goal_weight') or "nicht gesetzt", + "goal_bf_pct": prof.get('goal_bf_pct') or "nicht gesetzt", + "weight_aktuell": weight[0]['weight'] if weight else "keine Daten", + "kf_aktuell": caliper[0]['body_fat_pct'] if caliper and caliper[0].get('body_fat_pct') else "unbekannt", } - # Weight trend - if weights: - w_first=weights[-1]; w_last=weights[0] - diff=round(w_last['weight']-w_first['weight'],1) - vars["weight_trend"] = f"{w_first['date']}: {w_first['weight']}kg → {w_last['date']}: {w_last['weight']}kg (Δ{diff:+.1f}kg über {len(weights)} Einträge)" + # 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["weight_trend"] = "Keine Daten" + vars['age'] = "unbekannt" - # Caliper - if calipers: - ca=calipers[0] - vars["caliper_summary"] = f"KF: {ca.get('body_fat_pct')}% · Mager: {ca.get('lean_mass')}kg · Fett: {ca.get('fat_mass')}kg ({ca.get('date')})" - if len(calipers)>1: - prev=calipers[1]; diff=round((ca.get('body_fat_pct') or 0)-(prev.get('body_fat_pct') or 0),1) - vars["caliper_summary"] += f" | Vorher: {prev.get('body_fat_pct')}% (Δ{diff:+.1f}%)" + # Weight trend summary + if len(weight) >= 2: + recent = weight[:30] + delta = recent[0]['weight'] - recent[-1]['weight'] + vars['weight_trend'] = f"{len(recent)} Einträge, Δ30d: {delta:+.1f}kg" else: - vars["caliper_summary"] = "Keine Messungen" - - # Circumferences - if circs: - c=circs[0] - parts=[f"{k.replace('c_','').capitalize()}: {c[k]}cm" for k in ['c_waist','c_hip','c_belly','c_chest','c_arm'] if c.get(k)] - vars["circ_summary"] = f"{' · '.join(parts)} ({c.get('date')})" - else: - vars["circ_summary"] = "Keine Messungen" - - # Nutrition - if nutrition: - avg_kcal=round(sum(n['kcal'] or 0 for n in nutrition)/len(nutrition)) - avg_prot=round(sum(n['protein_g'] or 0 for n in nutrition)/len(nutrition),1) - avg_fat =round(sum(n['fat_g'] or 0 for n in nutrition)/len(nutrition),1) - avg_carb=round(sum(n['carbs_g'] or 0 for n in nutrition)/len(nutrition),1) - vars["nutrition_summary"] = f"{len(nutrition)} Tage · Ø {avg_kcal} kcal · Ø {avg_prot}g Protein · Ø {avg_fat}g Fett · Ø {avg_carb}g KH" - vars["nutrition_detail"] = str([{"date":n['date'],"kcal":round(n['kcal'] or 0),"protein_g":n['protein_g'],"fat_g":n['fat_g'],"carbs_g":n['carbs_g']} for n in nutrition]) - latest_w = weights[0]['weight'] if weights else 80 - vars["protein_ziel_low"] = str(round(latest_w*1.6,0)) - vars["protein_ziel_high"] = str(round(latest_w*2.2,0)) - else: - vars["nutrition_summary"] = "Keine Ernährungsdaten" - vars["nutrition_detail"] = "[]" - vars["protein_ziel_low"] = "–" - vars["protein_ziel_high"] = "–" - - # Activity - if activities: - total_kcal=round(sum(a.get('kcal_active') or 0 for a in activities)) - total_min=round(sum(a.get('duration_min') or 0 for a in activities)) - types={} - for a in activities: t=a['activity_type']; types.setdefault(t,0); types[t]+=1 - vars["activity_summary"] = f"{len(activities)} Trainings · {total_kcal} kcal · {total_min} Min · {types}" - vars["activity_kcal_summary"] = f"Ø {round(total_kcal/len(activities))} kcal/Training · {total_kcal} kcal gesamt ({len(activities)} Einheiten)" - vars["activity_detail"] = str([{"date":a['date'],"type":a['activity_type'],"min":a.get('duration_min'),"kcal":a.get('kcal_active'),"hr_avg":round(a['hr_avg']) if a.get('hr_avg') else None} for a in activities]) - else: - vars["activity_summary"] = "Keine Aktivitätsdaten" - vars["activity_kcal_summary"] = "Keine Daten" - vars["activity_detail"] = "[]" - - # Fill template - prompt = template - for key, val in vars.items(): - prompt = prompt.replace(f"{{{{{key}}}}}", val) - - try: - if OPENROUTER_KEY: - import httpx - resp=httpx.post("https://openrouter.ai/api/v1/chat/completions", - headers={"Authorization":f"Bearer {OPENROUTER_KEY}"}, - json={"model":OPENROUTER_MODEL,"messages":[{"role":"user","content":prompt}],"max_tokens":2500}, - timeout=60) - text=resp.json()['choices'][0]['message']['content'] - elif ANTHROPIC_KEY: - import anthropic - client=anthropic.Anthropic(api_key=ANTHROPIC_KEY) - msg=client.messages.create(model="claude-sonnet-4-20250514",max_tokens=2500, - messages=[{"role":"user","content":prompt}]) - text=msg.content[0].text - else: - raise HTTPException(400,"Kein API-Key") - - iid=str(uuid.uuid4()) - with get_db() as conn: - conn.execute("INSERT INTO ai_insights (id,profile_id,scope,content,created) VALUES (?,?,?,?,datetime('now'))", - (iid,pid,slug,text)); conn.commit() - return {"id":iid,"content":text,"scope":slug} - except HTTPException: raise - except Exception as e: raise HTTPException(500,f"AI-Fehler: {e}") - -# Keep legacy endpoint working -@app.post("/api/insights/trend") -def insight_trend_legacy(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - return run_insight("gesamt", x_profile_id) - -# ── Multi-Stage AI Pipeline ─────────────────────────────────────────────────── -import concurrent.futures - -def _call_ai(prompt: str, max_tokens: int = 600, json_mode: bool = False) -> str: - """Single AI call – used by pipeline stages.""" - system = "Du bist ein präziser Datenanalyst. " + ( - "Antworte NUR mit validem JSON, ohne Kommentare oder Markdown-Backticks." - if json_mode else - "Antworte auf Deutsch, sachlich und motivierend." - ) - if OPENROUTER_KEY: - import httpx, json as json_lib - resp = httpx.post("https://openrouter.ai/api/v1/chat/completions", - headers={"Authorization": f"Bearer {OPENROUTER_KEY}"}, - json={"model": OPENROUTER_MODEL, - "messages": [{"role":"system","content":system},{"role":"user","content":prompt}], - "max_tokens": max_tokens}, - timeout=60) - return resp.json()['choices'][0]['message']['content'] - elif ANTHROPIC_KEY: - import anthropic - client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) - msg = client.messages.create( - model="claude-sonnet-4-20250514", max_tokens=max_tokens, - system=system, - messages=[{"role":"user","content":prompt}]) - return msg.content[0].text - raise HTTPException(400, "Kein API-Key konfiguriert") - -@app.post("/api/insights/pipeline") -def run_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - # Pipeline counts as 3 calls (stage1 x3 + stage2 + stage3 = 5, but we count 3) - """ - 3-stage parallel AI pipeline: - Stage 1 (parallel): body_summary, nutrition_summary, activity_summary → compact JSON - Stage 2 (sequential): full narrative synthesis from summaries - Stage 3 (sequential): goal progress assessment - Final result saved as scope='pipeline' - """ - pid = get_pid(x_profile_id) - check_ai_limit(pid) # counts as 1 (pipeline run) - import json as json_lib - - with get_db() as conn: - profile = r2d(conn.execute("SELECT * FROM profiles WHERE id=?",(pid,)).fetchone()) or {} - weights = [r2d(r) for r in conn.execute("SELECT date,weight FROM weight_log WHERE profile_id=? ORDER BY date DESC LIMIT 14",(pid,)).fetchall()] - calipers = [r2d(r) for r in conn.execute("SELECT date,body_fat_pct,lean_mass,fat_mass FROM caliper_log WHERE profile_id=? ORDER BY date DESC LIMIT 5",(pid,)).fetchall()] - circs = [r2d(r) for r in conn.execute("SELECT date,c_waist,c_hip,c_belly FROM circumference_log WHERE profile_id=? ORDER BY date DESC LIMIT 5",(pid,)).fetchall()] - nutrition = [r2d(r) for r in conn.execute("SELECT date,kcal,protein_g,fat_g,carbs_g FROM nutrition_log WHERE profile_id=? ORDER BY date DESC LIMIT 14",(pid,)).fetchall()] - activities= [r2d(r) for r in conn.execute("SELECT date,activity_type,duration_min,kcal_active,hr_avg FROM activity_log WHERE profile_id=? ORDER BY date DESC LIMIT 20",(pid,)).fetchall()] - - name = profile.get('name','') - sex = profile.get('sex','m') - height = profile.get('height',178) - age = round((datetime.now()-datetime.strptime(profile['dob'],'%Y-%m-%d')).days/365.25) if profile.get('dob') else 30 - g_weight= profile.get('goal_weight','–') - g_bf = profile.get('goal_bf_pct','–') - - # Weight summary - w_trend = "" - if weights: - first=weights[-1]; last=weights[0] - diff=round(last['weight']-first['weight'],1) - w_trend=f"{first['date']}: {first['weight']}kg → {last['date']}: {last['weight']}kg (Δ{diff:+.1f}kg)" + vars['weight_trend'] = "zu wenig Daten" # Caliper summary - ca_sum = "" - if calipers: - c=calipers[0] - ca_sum=f"KF {c.get('body_fat_pct')}% Mager {c.get('lean_mass')}kg Fett {c.get('fat_mass')}kg ({c.get('date')})" + if caliper: + c = caliper[0] + vars['caliper_summary'] = f"KF: {c.get('body_fat_pct','?')}%, Methode: {c.get('sf_method','?')}" + else: + vars['caliper_summary'] = "keine Daten" - # Circ summary - ci_sum = "" - if circs: - c=circs[0] - ci_sum=f"Taille {c.get('c_waist')}cm Hüfte {c.get('c_hip')}cm Bauch {c.get('c_belly')}cm" + # 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]}: {c[k]}cm") + vars['circ_summary'] = ", ".join(parts) if parts else "keine Daten" + else: + vars['circ_summary'] = "keine Daten" # Nutrition summary - avg_kcal=avg_prot=avg_fat=avg_carb=None if nutrition: - n=len(nutrition) - avg_kcal=round(sum(x['kcal'] or 0 for x in nutrition)/n) - avg_prot=round(sum(x['protein_g'] or 0 for x in nutrition)/n,1) - avg_fat =round(sum(x['fat_g'] or 0 for x in nutrition)/n,1) - avg_carb=round(sum(x['carbs_g'] or 0 for x in nutrition)/n,1) - pt_low=round((weights[0]['weight'] if weights else 80)*1.6) - pt_high=round((weights[0]['weight'] if weights else 80)*2.2) + n = len(nutrition) + avg_kcal = sum(d.get('kcal',0) for d in nutrition) / n + avg_prot = sum(d.get('protein_g',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(d.get('fat_g',0) for d in nutrition) / n,1) + vars['carb_avg'] = round(sum(d.get('carbs_g',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 + vars['protein_ziel_low'] = round(w * 1.6) + vars['protein_ziel_high'] = round(w * 2.2) # Activity summary - act_sum="" - if activities: - total_kcal=round(sum(a.get('kcal_active') or 0 for a in activities)) - total_min=round(sum(a.get('duration_min') or 0 for a in activities)) - types={} - for a in activities: t=a['activity_type']; types.setdefault(t,0); types[t]+=1 - act_sum=f"{len(activities)} Einheiten {total_kcal}kcal {total_min}min Typen:{types}" + if activity: + n = len(activity) + total_kcal = sum(a.get('kcal_active',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" - # ── Load pipeline prompts from DB ───────────────────────────────────── + return vars + +@app.post("/api/ai/analyze/{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: - p_rows = {r['slug']:r['template'] for r in conn.execute( - "SELECT slug,template FROM ai_prompts WHERE slug LIKE 'pipeline_%'" - ).fetchall()} + cur = conn.cursor() + cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=1", (slug,)) + prompt_row = cur.fetchone() + if not prompt_row: + raise HTTPException(404, f"Prompt '{slug}' nicht gefunden") - def fill(template, extra={}): - """Fill template variables.""" - vars = { - 'name': name, - 'geschlecht': 'männlich' if sex=='m' else 'weiblich', - 'height': str(height), - 'age': str(age), - 'weight_trend': w_trend or 'Keine Daten', - 'caliper_summary':ca_sum or 'Keine Daten', - 'circ_summary': ci_sum or 'Keine Daten', - 'goal_weight': str(g_weight), - 'goal_bf_pct': str(g_bf), - 'kcal_avg': str(avg_kcal or '–'), - 'protein_avg': str(avg_prot or '–'), - 'fat_avg': str(avg_fat or '–'), - 'carb_avg': str(avg_carb or '–'), - 'nutrition_days': str(len(nutrition)), - 'weight_aktuell': str(weights[0]['weight'] if weights else '–'), - 'protein_ziel_low': str(pt_low), - 'protein_ziel_high': str(pt_high), - 'activity_detail': act_sum or 'Keine Daten', - } - vars.update(extra) - result = template - for k, v in vars.items(): - result = result.replace(f'{{{{{k}}}}}', v) - return result + prompt_tmpl = prompt_row['template'] + data = _get_profile_data(pid) + vars = _prepare_template_vars(data) + final_prompt = _render_template(prompt_tmpl, vars) - # ── Stage 1: Three parallel JSON analysis calls ──────────────────────── - default_body = f"""Analysiere diese Körperdaten und gib NUR ein JSON-Objekt zurück. -Profil: {sex} {height}cm {age}J Gewicht: {w_trend} Caliper: {ca_sum} Umfänge: {ci_sum} Ziele: {g_weight}kg KF {g_bf}% -{{"gewicht_trend":"sinkend|steigend|stabil","gewicht_delta_30d":,"kf_aktuell":,"kf_trend":"sinkend|steigend|stabil","whr_status":"gut|grenzwertig|erhoeht","koerper_bewertung":"<1 Satz>","koerper_auffaelligkeiten":"<1 Satz>"}}""" + # 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") - default_nutr = f"""Analysiere Ernährungsdaten und gib NUR JSON zurück. -Ø {avg_kcal}kcal {avg_prot}g P {avg_fat}g F {avg_carb}g KH ({len(nutrition)} Tage) Protein-Ziel {pt_low}–{pt_high}g -{{"kcal_avg":{avg_kcal},"protein_avg":{avg_prot},"protein_ziel_erreicht":,"kalorienbilanz":"defizit|ausgeglichen|ueberschuss","ernaehrung_bewertung":"<1 Satz>","ernaehrung_empfehlung":"<1 Tipp>"}}""" if nutrition else '{{"keine_daten":true}}' + # Save insight + with get_db() as conn: + cur = conn.cursor() + 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)) - default_act = f"""Analysiere Aktivitätsdaten und gib NUR JSON zurück. -{act_sum} -{{"trainings_anzahl":,"kcal_gesamt":,"konsistenz":"hoch|mittel|niedrig","haupttrainingsart":"","aktivitaet_bewertung":"<1 Satz>","aktivitaet_empfehlung":"<1 Tipp>"}}""" if activities else '{{"keine_daten":true}}' + inc_ai_usage(pid) + return {"scope": slug, "content": content} - prompt_body = fill(p_rows.get('pipeline_body', default_body)) - prompt_nutr = fill(p_rows.get('pipeline_nutrition', default_nutr)) - prompt_act = fill(p_rows.get('pipeline_activity', default_act)) +@app.post("/api/ai/analyze-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) - # Run stage 1 in parallel - try: - with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: - f_body = ex.submit(_call_ai, prompt_body, 400, True) - f_nutr = ex.submit(_call_ai, prompt_nutr, 300, True) - f_act = ex.submit(_call_ai, prompt_act, 250, True) - body_json = f_body.result(timeout=45) - nutr_json = f_nutr.result(timeout=45) - act_json = f_act.result(timeout=45) - except Exception as e: - raise HTTPException(500, f"Stage-1-Fehler: {e}") + data = _get_profile_data(pid) + vars = _prepare_template_vars(data) - # Clean JSON (remove potential markdown fences) - def clean_json(s): - s = s.strip() - if s.startswith("```"): s = s.split("\n",1)[1].rsplit("```",1)[0] - return s + # Stage 1: Parallel JSON analyses + with get_db() as conn: + cur = conn.cursor() + cur.execute("SELECT slug, template FROM ai_prompts WHERE slug LIKE 'pipeline_%' AND slug NOT IN ('pipeline_synthesis','pipeline_goals') AND active=1") + stage1_prompts = [r2d(r) for r in cur.fetchall()] - # ── Stage 2: Narrative synthesis ────────────────────────────────────── - default_synthesis = f"""Du bist Gesundheitscoach. Erstelle vollständige Analyse für {name} auf Deutsch (450–550 Wörter). -Körper: {clean_json(body_json)} Ernährung: {clean_json(nutr_json)} Aktivität: {clean_json(act_json)} -Protein-Ziel: {pt_low}–{pt_high}g/Tag -⚖️ **Gewichts- & Körperzusammensetzung** 🍽️ **Ernährungsanalyse** 🏋️ **Aktivität & Energiebilanz** 🔗 **Zusammenhänge** 💪 **3 Empfehlungen** -Sachlich, motivierend, Zahlen zitieren, keine Diagnosen.""" + stage1_results = {} + for p in stage1_prompts: + slug = p['slug'] + final_prompt = _render_template(p['template'], vars) - synth_template = p_rows.get('pipeline_synthesis', default_synthesis) - prompt_synthesis = fill(synth_template, { - 'stage1_body': clean_json(body_json), - 'stage1_nutrition': clean_json(nutr_json), - 'stage1_activity': clean_json(act_json), - }) + 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: - synthesis = _call_ai(prompt_synthesis, 2000, False) - except Exception as e: - raise HTTPException(500, f"Stage-2-Fehler: {e}") - - # ── Stage 3: Goal assessment (only if goals defined) ─────────────────── - goal_text = "" - if g_weight != '–' or g_bf != '–': - default_goals = f"""Ziel-Bewertung für {name} (100–150 Wörter): -Ziel: {g_weight}kg KF {g_bf}% | Körper: {clean_json(body_json)} -🎯 **Zielfortschritt** Abstand, Zeitfenster, nächste Schritte.""" - goals_template = p_rows.get('pipeline_goals', default_goals) - prompt_goals = fill(goals_template, { - 'stage1_body': clean_json(body_json), - }) + # Try to parse JSON, fallback to raw text try: - goal_text = "\n\n" + _call_ai(prompt_goals, 400, False) - except Exception as e: - goal_text = f"\n\n🎯 **Zielfortschritt**\n(Fehler: {e})" + stage1_results[slug] = json.loads(content) + except: + stage1_results[slug] = content - final_text = synthesis + goal_text + # 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) - # Save result - iid = str(uuid.uuid4()) with get_db() as conn: - conn.execute("INSERT INTO ai_insights (id,profile_id,scope,content,created) VALUES (?,?,?,?,datetime('now'))", - (iid, pid, 'pipeline', final_text)) - conn.commit() + cur = conn.cursor() + cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=1") + 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 = conn.cursor() + cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_goals' AND active=1") + 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 = conn.cursor() + 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/ai/prompts") +def list_prompts(session: dict=Depends(require_auth)): + """List all available AI prompts.""" + with get_db() as conn: + cur = conn.cursor() + cur.execute("SELECT * FROM ai_prompts WHERE active=1 AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order") + return [r2d(r) for r in cur.fetchall()] + +@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 = conn.cursor() + 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 { - "id": iid, - "content": final_text, - "scope": "pipeline", - "stage1": { - "body": clean_json(body_json), - "nutrition": clean_json(nutr_json), - "activity": clean_json(act_json), - } + "limit": limit, + "used_today": used, + "remaining": (limit - used) if limit else None, + "history": history } # ── Auth ────────────────────────────────────────────────────────────────────── class LoginRequest(BaseModel): - email: Optional[str] = None - name: Optional[str] = None - profile_id: Optional[str] = None - pin: Optional[str] = None + email: str + password: str -class SetupRequest(BaseModel): - name: str - pin: str - auth_type: Optional[str] = 'pin' - session_days: Optional[int] = 30 - avatar_color: Optional[str] = '#1D9E75' - sex: Optional[str] = 'm' - height: Optional[float] = 178 +class PasswordResetRequest(BaseModel): + email: str -class ProfilePermissions(BaseModel): - role: Optional[str] = None - ai_enabled: Optional[int] = None - ai_limit_day: Optional[int] = None - export_enabled: Optional[int] = None - auth_type: Optional[str] = None - session_days: Optional[int] = None - -@app.get("/api/auth/status") -def auth_status(): - """Check if any profiles exist (for first-run setup detection).""" - with get_db() as conn: - count = conn.execute("SELECT COUNT(*) FROM profiles").fetchone()[0] - has_pin = conn.execute("SELECT COUNT(*) FROM profiles WHERE pin_hash IS NOT NULL").fetchone()[0] - return {"needs_setup": count == 0, "has_auth": has_pin > 0, "profile_count": count} - -@app.post("/api/auth/setup") -def first_setup(req: SetupRequest): - """First-run: create admin profile.""" - with get_db() as conn: - count = conn.execute("SELECT COUNT(*) FROM profiles").fetchone()[0] - # Allow setup if no profiles OR no profile has a PIN yet - has_pin = conn.execute("SELECT COUNT(*) FROM profiles WHERE pin_hash IS NOT NULL").fetchone()[0] - if count > 0 and has_pin > 0: - raise HTTPException(400, "Setup bereits abgeschlossen") - pid = str(uuid.uuid4()) - conn.execute("""INSERT INTO profiles - (id,name,avatar_color,sex,height,role,pin_hash,auth_type,session_days, - ai_enabled,export_enabled,created,updated) - VALUES (?,?,?,?,?,'admin',?,?,?,1,1,datetime('now'),datetime('now'))""", - (pid, req.name, req.avatar_color, req.sex, req.height, - hash_pin(req.pin), req.auth_type, req.session_days)) - # Create session - token = make_token() - expires = (datetime.now()+timedelta(days=req.session_days)).isoformat() - conn.execute("INSERT INTO sessions (token,profile_id,expires_at) VALUES (?,?,?)", - (token, pid, expires)) - conn.commit() - return {"token": token, "profile_id": pid, "role": "admin"} +class PasswordResetConfirm(BaseModel): + token: str + new_password: str @app.post("/api/auth/login") @limiter.limit("5/minute") -def login(request: Request, req: LoginRequest): - """Login via email or username + password. Auto-upgrades SHA256 to bcrypt.""" +async def login(req: LoginRequest, request: Request): + """Login with email + password.""" with get_db() as conn: - # Support login via email OR name - profile = None - if req.email: - profile = r2d(conn.execute( - "SELECT * FROM profiles WHERE LOWER(email)=?", - (req.email.strip().lower(),)).fetchone()) - if not profile and req.name: - profile = r2d(conn.execute( - "SELECT * FROM profiles WHERE LOWER(name)=?", - (req.name.strip().lower(),)).fetchone()) - # Legacy: support profile_id for self-hosted - if not profile and req.profile_id: - profile = r2d(conn.execute( - "SELECT * FROM profiles WHERE id=?", (req.profile_id,)).fetchone()) - - if not profile: - raise HTTPException(401, "Ungültige E-Mail oder Passwort") + cur = conn.cursor() + 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 profile.get('pin_hash'): - # No password set - allow for legacy/setup - pass - elif not verify_pin(req.pin or "", profile['pin_hash']): - raise HTTPException(401, "Ungültige E-Mail oder Passwort") - else: - # Auto-upgrade SHA256 → bcrypt on successful login - if profile['pin_hash'] and not profile['pin_hash'].startswith('$2'): - new_hash = hash_pin(req.pin) - conn.execute("UPDATE profiles SET pin_hash=? WHERE id=?", - (new_hash, profile['id'])) - conn.commit() + 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() - days = profile.get('session_days') or 30 - expires = (datetime.now() + timedelta(days=days)).isoformat() - conn.execute( - "INSERT INTO sessions (token, profile_id, expires_at, created) " - "VALUES (?, ?, ?, datetime('now'))", - (token, profile['id'], expires)) - conn.commit() + 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": profile['id'], - "name": profile['name'], - "role": profile['role'], - "expires_at": expires + "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: - conn.execute("DELETE FROM sessions WHERE token=?", (x_auth_token,)); conn.commit() + cur = conn.cursor() + cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,)) return {"ok": True} @app.get("/api/auth/me") -def get_me(session=Depends(require_auth)): - with get_db() as conn: - profile = r2d(conn.execute("SELECT * FROM profiles WHERE id=?", (session['profile_id'],)).fetchone()) - return {**profile, "role": session['role']} +def get_me(session: dict=Depends(require_auth)): + """Get current user info.""" + pid = session['profile_id'] + return get_profile(pid, session) -@app.put("/api/auth/pin") -def change_pin(data: dict, session=Depends(require_auth)): - new_pin = data.get('pin','') - if len(new_pin) < 4: raise HTTPException(400, "PIN mind. 4 Zeichen") +@app.post("/api/auth/password-reset-request") +@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: - conn.execute("UPDATE profiles SET pin_hash=? WHERE id=?", - (hash_pin(new_pin), session['profile_id'])); conn.commit() - return {"ok": True} + cur = conn.cursor() + 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."} -# ── Admin: Profile permissions ──────────────────────────────────────────────── -@app.put("/api/admin/profiles/{pid}/permissions") -def set_permissions(pid: str, p: ProfilePermissions, session=Depends(require_admin)): + # 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/password-reset-confirm") +def password_reset_confirm(req: PasswordResetConfirm): + """Confirm password reset with token.""" with get_db() as conn: - data = {k:v for k,v in p.model_dump().items() if v is not None} - if not data: return {"ok": True} - conn.execute(f"UPDATE profiles SET {', '.join(f'{k}=?' for k in data)} WHERE id=?", - list(data.values())+[pid]) - conn.commit() - return {"ok": True} + cur = conn.cursor() + 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 ───────────────────────────────────────────────────────────────────── +class AdminProfileUpdate(BaseModel): + role: Optional[str] = None + ai_enabled: Optional[int] = None + ai_limit_day: Optional[int] = None + export_enabled: Optional[int] = None @app.get("/api/admin/profiles") -def admin_list_profiles(session=Depends(require_admin)): +def admin_list_profiles(session: dict=Depends(require_admin)): + """Admin: List all profiles with stats.""" with get_db() as conn: - rows = conn.execute("SELECT * FROM profiles ORDER BY created").fetchall() - # Include AI usage today - today = datetime.now().strftime('%Y-%m-%d') - usage = {r['profile_id']:r['call_count'] for r in conn.execute( - "SELECT profile_id, call_count FROM ai_usage WHERE date=?", (today,)).fetchall()} - result = [] - for r in rows: - d = r2d(r) - d['ai_calls_today'] = usage.get(d['id'], 0) - result.append(d) - return result + cur = conn.cursor() + cur.execute("SELECT * FROM profiles ORDER BY created") + profs = [r2d(r) for r in cur.fetchall()] -@app.delete("/api/admin/profiles/{pid}") -def admin_delete_profile(pid: str, session=Depends(require_admin)): - if pid == session['profile_id']: - raise HTTPException(400, "Eigenes Profil kann nicht gelöscht werden") + for p in profs: + pid = p['id'] + cur.execute("SELECT COUNT(*) FROM weight_log WHERE profile_id=%s", (pid,)) + p['weight_count'] = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM ai_insights WHERE profile_id=%s", (pid,)) + p['ai_insights_count'] = cur.fetchone()[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() + 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: - target = r2d(conn.execute("SELECT role FROM profiles WHERE id=?", (pid,)).fetchone()) - if target and target['role'] == 'admin': - admin_count = conn.execute("SELECT COUNT(*) FROM profiles WHERE role='admin'").fetchone()[0] - if admin_count <= 1: - raise HTTPException(400, "Letzter Admin kann nicht gelöscht werden. Erst einen anderen Admin ernennen.") - with get_db() as conn: - for table in ['weight_log','circumference_log','caliper_log', - 'nutrition_log','activity_log','ai_insights','sessions']: - conn.execute(f"DELETE FROM {table} WHERE profile_id=?", (pid,)) - conn.execute("DELETE FROM profiles WHERE id=?", (pid,)) - conn.commit() + updates = {k:v for k,v in data.model_dump().items() if v is not None} + if not updates: + return {"ok": True} + + cur = conn.cursor() + 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.post("/api/admin/profiles") -def admin_create_profile(p: SetupRequest, session=Depends(require_admin)): - pid = str(uuid.uuid4()) - with get_db() as conn: - conn.execute("""INSERT INTO profiles - (id,name,avatar_color,sex,height,role,pin_hash,auth_type,session_days, - ai_enabled,export_enabled,created,updated) - VALUES (?,?,?,?,?,'user',?,?,?,1,1,datetime('now'),datetime('now'))""", - (pid, p.name, p.avatar_color, p.sex, p.height, - hash_pin(p.pin), p.auth_type, p.session_days)) - conn.commit() - with get_db() as conn: - return r2d(conn.execute("SELECT * FROM profiles WHERE id=?", (pid,)).fetchone()) - -# ── AI Usage tracking ───────────────────────────────────────────────────────── -def check_ai_limit(pid: str): - """Check and increment AI usage. Raises 429 if limit exceeded.""" - with get_db() as conn: - profile = r2d(conn.execute("SELECT ai_enabled, ai_limit_day, role FROM profiles WHERE id=?", (pid,)).fetchone()) - if not profile: raise HTTPException(404) - if not profile.get('ai_enabled'): - raise HTTPException(403, "KI-Zugang für dieses Profil nicht aktiviert") - today = datetime.now().strftime('%Y-%m-%d') - limit = profile.get('ai_limit_day') - if limit: - usage_row = conn.execute("SELECT call_count FROM ai_usage WHERE profile_id=? AND date=?", (pid,today)).fetchone() - count = usage_row['call_count'] if usage_row else 0 - if count >= limit: - raise HTTPException(429, f"Tages-Limit von {limit} KI-Calls erreicht") - # Increment - conn.execute("""INSERT INTO ai_usage (id,profile_id,date,call_count) - VALUES (?,?,?,1) - ON CONFLICT(profile_id,date) DO UPDATE SET call_count=call_count+1""", - (str(uuid.uuid4()), pid, today)) - conn.commit() - -# Admin email update for profiles -@app.put("/api/admin/profiles/{pid}/email") -def admin_set_email(pid: str, data: dict, session=Depends(require_admin)): - email = data.get('email','').strip() - with get_db() as conn: - conn.execute("UPDATE profiles SET email=? WHERE id=?", (email or None, pid)) - conn.commit() - return {"ok": True} - -# Admin PIN reset for other profiles -@app.put("/api/admin/profiles/{pid}/pin") -def admin_set_pin(pid: str, data: dict, session=Depends(require_admin)): - new_pin = data.get('pin','') - if len(new_pin) < 4: raise HTTPException(400, "PIN mind. 4 Zeichen") - with get_db() as conn: - conn.execute("UPDATE profiles SET pin_hash=? WHERE id=?", (hash_pin(new_pin), pid)) - conn.commit() - return {"ok": True} - -# ── E-Mail Infrastructure ───────────────────────────────────────────────────── -import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart - -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', SMTP_USER) -APP_URL = os.getenv('APP_URL', 'http://localhost:3002') - -def send_email(to: str, subject: str, html: str, text: str = '') -> bool: - """Send email via configured SMTP. Returns True on success.""" - if not SMTP_HOST or not SMTP_USER: - print(f"[EMAIL] SMTP not configured – would send to {to}: {subject}") - return False +@app.post("/api/admin/test-email") +def admin_test_email(email: str, session: dict=Depends(require_admin)): + """Admin: Send test email.""" try: - msg = MIMEMultipart('alternative') - msg['Subject'] = subject - msg['From'] = f"Mitai Jinkendo <{SMTP_FROM}>" - msg['To'] = to - if text: msg.attach(MIMEText(text, 'plain', 'utf-8')) - msg.attach(MIMEText(html, 'html', 'utf-8')) - with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as s: - s.ehlo() - s.starttls() - s.login(SMTP_USER, SMTP_PASS) - s.sendmail(SMTP_FROM, [to], msg.as_string()) - print(f"[EMAIL] Sent to {to}: {subject}") - return True + 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: - print(f"[EMAIL] Error sending to {to}: {e}") - return False + raise HTTPException(500, f"Fehler beim Senden: {str(e)}") -def email_html_wrapper(content_html: str, title: str) -> str: - return f""" - - -
- -
{title}
- {content_html} - -
""" - -# ── Password Recovery ───────────────────────────────────────────────────────── -import random, string - -def generate_recovery_token() -> str: - return ''.join(random.choices(string.ascii_letters + string.digits, k=32)) - -@app.post("/api/auth/forgot-password") -@limiter.limit("3/minute") -def forgot_password(request: Request, data: dict): - """Send recovery email if profile has email configured.""" - email = data.get('email','').strip().lower() - if not email: raise HTTPException(400, "E-Mail erforderlich") +# ── 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: - profile = conn.execute( - "SELECT * FROM profiles WHERE LOWER(email)=?", (email,) - ).fetchone() - if not profile: - # Don't reveal if email exists - return {"ok": True, "message": "Falls ein Konto existiert, wurde eine E-Mail gesendet."} - profile = r2d(profile) + cur = conn.cursor() + 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") - # Generate token, valid 1 hour - token = generate_recovery_token() - expires = (datetime.now()+timedelta(hours=1)).isoformat() - conn.execute( - "INSERT OR REPLACE INTO sessions (token, profile_id, expires_at, created) " - "VALUES (?, ?, ?, datetime('now'))", - (f"recovery_{token}", profile['id'], expires) - ) - conn.commit() + # Build CSV + output = io.StringIO() + writer = csv.writer(output) - reset_url = f"{APP_URL}/reset-password?token={token}" - html = email_html_wrapper(f""" -

Hallo {profile['name']},

-

du hast eine Passwort-Zurücksetzung für dein Mitai Jinkendo-Konto angefordert.

- Passwort zurücksetzen -

Dieser Link ist 1 Stunde gültig.
- Falls du das nicht angefordert hast, ignoriere diese E-Mail.

-
-

Oder kopiere diesen Link:
- {reset_url}

- """, "Passwort zurücksetzen") - - sent = send_email(email, "Mitai Jinkendo – Passwort zurücksetzen", html) - return {"ok": True, "message": "Falls ein Konto existiert, wurde eine E-Mail gesendet.", "sent": sent} - -@app.post("/api/auth/reset-password") -@limiter.limit("3/minute") -def reset_password(request: Request, data: dict): - """Reset password using recovery token.""" - token = data.get('token','') - new_pin = data.get('pin','') - if not token or len(new_pin) < 4: - raise HTTPException(400, "Token und neues Passwort erforderlich") + # Header + writer.writerow(["Typ", "Datum", "Wert", "Details"]) + # Weight with get_db() as conn: - session = conn.execute( - "SELECT * FROM sessions WHERE token=? AND expires_at > datetime('now')", - (f"recovery_{token}",) - ).fetchone() - if not session: - raise HTTPException(400, "Ungültiger oder abgelaufener Token") - session = r2d(session) + cur = conn.cursor() + 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"{r['weight']}kg", r['note'] or ""]) - conn.execute("UPDATE profiles SET pin_hash=? WHERE id=?", - (hash_pin(new_pin), session['profile_id'])) - conn.execute("DELETE FROM sessions WHERE token=?", (f"recovery_{token}",)) - conn.commit() + # 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:{r['c_waist']}cm Bauch:{r['c_belly']}cm Hüfte:{r['c_hip']}cm" + writer.writerow(["Umfänge", r['date'], "", details]) - return {"ok": True} + # 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"{r['body_fat_pct']}%", f"Magermasse:{r['lean_mass']}kg"]) -# ── E-Mail Settings ─────────────────────────────────────────────────────────── -@app.get("/api/admin/email/status") -def email_status(session=Depends(require_admin)): - return { - "configured": bool(SMTP_HOST and SMTP_USER), - "smtp_host": SMTP_HOST, - "smtp_port": SMTP_PORT, - "smtp_user": SMTP_USER, - "from": SMTP_FROM, - "app_url": APP_URL, - } + # 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"{r['kcal']}kcal", f"Protein:{r['protein_g']}g"]) -@app.post("/api/admin/email/test") -def email_test(data: dict, session=Depends(require_admin)): - """Send a test email.""" - to = data.get('to','') - if not to: raise HTTPException(400, "Empfänger-E-Mail fehlt") - html = email_html_wrapper(""" -

Das ist eine Test-E-Mail von Mitai Jinkendo.

-

✓ E-Mail-Versand funktioniert korrekt!

- """, "Test-E-Mail") - sent = send_email(to, "Mitai Jinkendo – Test-E-Mail", html) - if not sent: raise HTTPException(500, "E-Mail konnte nicht gesendet werden. SMTP-Konfiguration prüfen.") - return {"ok": True} + # 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"{r['duration_min']}min {r['kcal_active']}kcal"]) -@app.post("/api/admin/email/weekly-summary/{pid}") -def send_weekly_summary(pid: str, session=Depends(require_admin)): - """Send weekly summary to a profile (if email configured).""" - with get_db() as conn: - profile = r2d(conn.execute("SELECT * FROM profiles WHERE id=?", (pid,)).fetchone()) - if not profile or not profile.get('email'): - raise HTTPException(400, "Profil hat keine E-Mail-Adresse") - - # Gather last 7 days data - weights = [r2d(r) for r in conn.execute( - "SELECT date,weight FROM weight_log WHERE profile_id=? AND date>=date('now','-7 days') ORDER BY date", - (pid,)).fetchall()] - nutr = [r2d(r) for r in conn.execute( - "SELECT kcal,protein_g FROM nutrition_log WHERE profile_id=? AND date>=date('now','-7 days')", - (pid,)).fetchall()] - acts = conn.execute( - "SELECT COUNT(*) FROM activity_log WHERE profile_id=? AND date>=date('now','-7 days')", - (pid,)).fetchone()[0] - - w_text = f"{weights[0]['weight']} kg → {weights[-1]['weight']} kg" if len(weights)>=2 else "Keine Daten" - n_text = f"Ø {round(sum(n['kcal'] or 0 for n in nutr)/len(nutr))} kcal" if nutr else "Keine Daten" - w_delta = round(weights[-1]['weight']-weights[0]['weight'],1) if len(weights)>=2 else None - if w_delta is not None: - color = "#1D9E75" if w_delta <= 0 else "#D85A30" - sign = "+" if w_delta > 0 else "" - delta_html = f"{sign}{w_delta} kg" - else: - delta_html = "" - - html = email_html_wrapper(f""" -

Hallo {profile['name']}, hier ist deine Wochenzusammenfassung:

- - - - - - - -
⚖️ Gewicht{w_text} {delta_html}
🍽️ Ernährung{n_text}
🏋️ Trainings{acts}× diese Woche
- App öffnen - """, "Deine Wochenzusammenfassung") - - sent = send_email(profile['email'], f"Mitai Jinkendo – Woche vom {datetime.now().strftime('%d.%m.%Y')}", html) - if not sent: raise HTTPException(500, "Senden fehlgeschlagen") - return {"ok": True} + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"} + ) diff --git a/backend/migrate_to_postgres.py b/backend/migrate_to_postgres.py new file mode 100644 index 0000000..b6a6dab --- /dev/null +++ b/backend/migrate_to_postgres.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +SQLite → PostgreSQL Migration Script für Mitai Jinkendo (v9a → v9b) + +Migrates all data from SQLite to PostgreSQL with type conversions and validation. + +Usage: + # Inside Docker container: + python migrate_to_postgres.py + + # Or locally with custom paths: + DATA_DIR=./data DB_HOST=localhost python migrate_to_postgres.py + +Environment Variables: + SQLite Source: + DATA_DIR (default: ./data) + + PostgreSQL Target: + DB_HOST (default: postgres) + DB_PORT (default: 5432) + DB_NAME (default: mitai) + DB_USER (default: mitai) + DB_PASSWORD (required) +""" +import os +import sys +import sqlite3 +from pathlib import Path +from typing import Dict, Any, List, Optional +import psycopg2 +from psycopg2.extras import execute_values, RealDictCursor + + +# ================================================================ +# CONFIGURATION +# ================================================================ + +# SQLite Source +DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) +SQLITE_DB = DATA_DIR / "bodytrack.db" + +# PostgreSQL Target +PG_CONFIG = { + 'host': os.getenv("DB_HOST", "postgres"), + 'port': int(os.getenv("DB_PORT", "5432")), + 'database': os.getenv("DB_NAME", "mitai"), + 'user': os.getenv("DB_USER", "mitai"), + 'password': os.getenv("DB_PASSWORD", "") +} + +# Tables to migrate (in order - respects foreign keys) +TABLES = [ + 'profiles', + 'sessions', + 'ai_usage', + 'ai_prompts', + 'weight_log', + 'circumference_log', + 'caliper_log', + 'nutrition_log', + 'activity_log', + 'photos', + 'ai_insights', +] + +# Columns that need INTEGER (0/1) → BOOLEAN conversion +BOOLEAN_COLUMNS = { + 'profiles': ['ai_enabled', 'export_enabled'], + 'ai_prompts': ['active'], +} + + +# ================================================================ +# CONVERSION HELPERS +# ================================================================ + +def convert_value(value: Any, column: str, table: str) -> Any: + """ + Convert SQLite value to PostgreSQL-compatible format. + + Args: + value: Raw value from SQLite + column: Column name + table: Table name + + Returns: + Converted value suitable for PostgreSQL + """ + # NULL values pass through + if value is None: + return None + + # INTEGER → BOOLEAN conversion + if table in BOOLEAN_COLUMNS and column in BOOLEAN_COLUMNS[table]: + return bool(value) + + # All other values pass through + # (PostgreSQL handles TEXT timestamps, UUIDs, and numerics automatically) + return value + + +def convert_row(row: Dict[str, Any], table: str) -> Dict[str, Any]: + """ + Convert entire row from SQLite to PostgreSQL format. + + Args: + row: Dictionary with column:value pairs from SQLite + table: Table name + + Returns: + Converted dictionary + """ + return { + column: convert_value(value, column, table) + for column, value in row.items() + } + + +# ================================================================ +# MIGRATION LOGIC +# ================================================================ + +def get_sqlite_rows(table: str) -> List[Dict[str, Any]]: + """ + Fetch all rows from SQLite table. + + Args: + table: Table name + + Returns: + List of dictionaries (one per row) + """ + conn = sqlite3.connect(SQLITE_DB) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + try: + rows = cur.execute(f"SELECT * FROM {table}").fetchall() + return [dict(row) for row in rows] + except sqlite3.OperationalError as e: + # Table doesn't exist in SQLite (OK, might be new in v9b) + print(f" ⚠ Table '{table}' not found in SQLite: {e}") + return [] + finally: + conn.close() + + +def migrate_table(pg_conn, table: str) -> Dict[str, int]: + """ + Migrate one table from SQLite to PostgreSQL. + + Args: + pg_conn: PostgreSQL connection + table: Table name + + Returns: + Dictionary with stats: {'sqlite_count': N, 'postgres_count': M} + """ + print(f" Migrating '{table}'...", end=' ', flush=True) + + # Fetch from SQLite + sqlite_rows = get_sqlite_rows(table) + sqlite_count = len(sqlite_rows) + + if sqlite_count == 0: + print("(empty)") + return {'sqlite_count': 0, 'postgres_count': 0} + + # Convert rows + converted_rows = [convert_row(row, table) for row in sqlite_rows] + + # Get column names + columns = list(converted_rows[0].keys()) + cols_str = ', '.join(columns) + placeholders = ', '.join(['%s'] * len(columns)) + + # Insert into PostgreSQL + pg_cur = pg_conn.cursor() + + # Build INSERT query + query = f"INSERT INTO {table} ({cols_str}) VALUES %s" + + # Prepare values (list of tuples) + values = [ + tuple(row[col] for col in columns) + for row in converted_rows + ] + + # Batch insert with execute_values (faster than executemany) + try: + execute_values(pg_cur, query, values, page_size=100) + except psycopg2.Error as e: + print(f"\n ✗ Insert failed: {e}") + raise + + # Verify row count + pg_cur.execute(f"SELECT COUNT(*) FROM {table}") + postgres_count = pg_cur.fetchone()[0] + + print(f"✓ {sqlite_count} rows → {postgres_count} rows") + + return { + 'sqlite_count': sqlite_count, + 'postgres_count': postgres_count + } + + +def verify_migration(pg_conn, stats: Dict[str, Dict[str, int]]): + """ + Verify migration integrity. + + Args: + pg_conn: PostgreSQL connection + stats: Migration stats per table + """ + print("\n═══════════════════════════════════════════════════════════") + print("VERIFICATION") + print("═══════════════════════════════════════════════════════════") + + all_ok = True + + for table, counts in stats.items(): + sqlite_count = counts['sqlite_count'] + postgres_count = counts['postgres_count'] + + status = "✓" if sqlite_count == postgres_count else "✗" + print(f" {status} {table:20s} SQLite: {sqlite_count:5d} → PostgreSQL: {postgres_count:5d}") + + if sqlite_count != postgres_count: + all_ok = False + + # Sample some data + print("\n───────────────────────────────────────────────────────────") + print("SAMPLE DATA (first profile)") + print("───────────────────────────────────────────────────────────") + + cur = pg_conn.cursor(cursor_factory=RealDictCursor) + cur.execute("SELECT * FROM profiles LIMIT 1") + profile = cur.fetchone() + + if profile: + for key, value in dict(profile).items(): + print(f" {key:20s} = {value}") + else: + print(" (no profiles found)") + + print("\n───────────────────────────────────────────────────────────") + print("SAMPLE DATA (latest weight entry)") + print("───────────────────────────────────────────────────────────") + + cur.execute("SELECT * FROM weight_log ORDER BY date DESC LIMIT 1") + weight = cur.fetchone() + + if weight: + for key, value in dict(weight).items(): + print(f" {key:20s} = {value}") + else: + print(" (no weight entries found)") + + print("\n═══════════════════════════════════════════════════════════") + + if all_ok: + print("✓ MIGRATION SUCCESSFUL - All row counts match!") + else: + print("✗ MIGRATION FAILED - Row count mismatch detected!") + sys.exit(1) + + +# ================================================================ +# MAIN +# ================================================================ + +def main(): + print("═══════════════════════════════════════════════════════════") + print("MITAI JINKENDO - SQLite → PostgreSQL Migration (v9a → v9b)") + print("═══════════════════════════════════════════════════════════\n") + + # Check SQLite DB exists + if not SQLITE_DB.exists(): + print(f"✗ SQLite database not found: {SQLITE_DB}") + print(f" Set DATA_DIR environment variable if needed.") + sys.exit(1) + + print(f"✓ SQLite source: {SQLITE_DB}") + print(f"✓ PostgreSQL target: {PG_CONFIG['user']}@{PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['database']}\n") + + # Check PostgreSQL password + if not PG_CONFIG['password']: + print("✗ DB_PASSWORD environment variable not set!") + sys.exit(1) + + # Connect to PostgreSQL + print("Connecting to PostgreSQL...", end=' ', flush=True) + try: + pg_conn = psycopg2.connect(**PG_CONFIG) + print("✓") + except psycopg2.Error as e: + print(f"\n✗ Connection failed: {e}") + print("\nTroubleshooting:") + print(" - Is PostgreSQL running? (docker compose ps)") + print(" - Is DB_PASSWORD correct?") + print(" - Is the schema initialized? (schema.sql loaded?)") + sys.exit(1) + + # Check if schema is initialized + print("Checking PostgreSQL schema...", end=' ', flush=True) + cur = pg_conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'profiles' + """) + if cur.fetchone()[0] == 0: + print("\n✗ Schema not initialized!") + print("\nRun this first:") + print(" docker compose exec backend python -c \"from main import init_db; init_db()\"") + print(" Or manually load schema.sql") + sys.exit(1) + print("✓") + + # Check if PostgreSQL is empty + print("Checking if PostgreSQL is empty...", end=' ', flush=True) + cur.execute("SELECT COUNT(*) FROM profiles") + existing_profiles = cur.fetchone()[0] + if existing_profiles > 0: + print(f"\n⚠ WARNING: PostgreSQL already has {existing_profiles} profiles!") + response = input(" Continue anyway? This will create duplicates! (yes/no): ") + if response.lower() != 'yes': + print("Migration cancelled.") + sys.exit(0) + else: + print("✓") + + print("\n───────────────────────────────────────────────────────────") + print("MIGRATION") + print("───────────────────────────────────────────────────────────") + + stats = {} + + try: + for table in TABLES: + stats[table] = migrate_table(pg_conn, table) + + # Commit all changes + pg_conn.commit() + print("\n✓ All changes committed to PostgreSQL") + + except Exception as e: + print(f"\n✗ Migration failed: {e}") + print("Rolling back...") + pg_conn.rollback() + pg_conn.close() + sys.exit(1) + + # Verification + verify_migration(pg_conn, stats) + + # Cleanup + pg_conn.close() + + print("\n✓ Migration complete!") + print("\nNext steps:") + print(" 1. Test login with existing credentials") + print(" 2. Check Dashboard (weight chart, stats)") + print(" 3. Verify KI-Analysen work") + print(" 4. If everything works: commit + push to develop") + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index e5781ac..99f7983 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ aiofiles==23.2.1 pydantic==2.7.1 bcrypt==4.1.3 slowapi==0.1.9 +psycopg2-binary==2.9.9 diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..56300f6 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,260 @@ +-- ================================================================ +-- MITAI JINKENDO v9b – PostgreSQL Schema +-- ================================================================ +-- Migration from SQLite to PostgreSQL +-- Includes v9b Tier System features +-- ================================================================ + +-- Enable UUID Extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ================================================================ +-- CORE TABLES +-- ================================================================ + +-- ── Profiles Table ────────────────────────────────────────────── +-- User/Profile management with auth and permissions +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL DEFAULT 'Nutzer', + avatar_color VARCHAR(7) DEFAULT '#1D9E75', + photo_id UUID, + sex VARCHAR(1) DEFAULT 'm' CHECK (sex IN ('m', 'w', 'd')), + dob DATE, + height NUMERIC(5,2) DEFAULT 178, + goal_weight NUMERIC(5,2), + goal_bf_pct NUMERIC(4,2), + + -- Auth & Permissions + role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('user', 'admin')), + pin_hash TEXT, + auth_type VARCHAR(20) DEFAULT 'pin' CHECK (auth_type IN ('pin', 'email')), + session_days INTEGER DEFAULT 30, + ai_enabled BOOLEAN DEFAULT TRUE, + ai_limit_day INTEGER, + export_enabled BOOLEAN DEFAULT TRUE, + email VARCHAR(255) UNIQUE, + + -- v9b: Tier System + tier VARCHAR(20) DEFAULT 'free' CHECK (tier IN ('free', 'basic', 'premium', 'selfhosted')), + tier_expires_at TIMESTAMP WITH TIME ZONE, + trial_ends_at TIMESTAMP WITH TIME ZONE, + invited_by UUID REFERENCES profiles(id), + + -- Timestamps + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_profiles_email ON profiles(email) WHERE email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_profiles_tier ON profiles(tier); + +-- ── Sessions Table ────────────────────────────────────────────── +-- Auth token management +CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(64) PRIMARY KEY, + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_sessions_profile_id ON sessions(profile_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +-- ── AI Usage Tracking ─────────────────────────────────────────── +-- Daily AI call limits per profile +CREATE TABLE IF NOT EXISTS ai_usage ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + call_count INTEGER DEFAULT 0, + UNIQUE(profile_id, date) +); + +CREATE INDEX IF NOT EXISTS idx_ai_usage_profile_date ON ai_usage(profile_id, date); + +-- ================================================================ +-- TRACKING TABLES +-- ================================================================ + +-- ── Weight Log ────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS weight_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + weight NUMERIC(5,2) NOT NULL, + note TEXT, + source VARCHAR(20) DEFAULT 'manual', + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_weight_log_profile_date ON weight_log(profile_id, date DESC); +CREATE UNIQUE INDEX IF NOT EXISTS idx_weight_log_profile_date_unique ON weight_log(profile_id, date); + +-- ── Circumference Log ─────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS circumference_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + c_neck NUMERIC(5,2), + c_chest NUMERIC(5,2), + c_waist NUMERIC(5,2), + c_belly NUMERIC(5,2), + c_hip NUMERIC(5,2), + c_thigh NUMERIC(5,2), + c_calf NUMERIC(5,2), + c_arm NUMERIC(5,2), + notes TEXT, + photo_id UUID, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_circumference_profile_date ON circumference_log(profile_id, date DESC); + +-- ── Caliper Log ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS caliper_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + sf_method VARCHAR(20) DEFAULT 'jackson3', + sf_chest NUMERIC(5,2), + sf_axilla NUMERIC(5,2), + sf_triceps NUMERIC(5,2), + sf_subscap NUMERIC(5,2), + sf_suprailiac NUMERIC(5,2), + sf_abdomen NUMERIC(5,2), + sf_thigh NUMERIC(5,2), + sf_calf_med NUMERIC(5,2), + sf_lowerback NUMERIC(5,2), + sf_biceps NUMERIC(5,2), + body_fat_pct NUMERIC(4,2), + lean_mass NUMERIC(5,2), + fat_mass NUMERIC(5,2), + notes TEXT, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_caliper_profile_date ON caliper_log(profile_id, date DESC); + +-- ── Nutrition Log ─────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS nutrition_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + kcal NUMERIC(7,2), + protein_g NUMERIC(6,2), + fat_g NUMERIC(6,2), + carbs_g NUMERIC(6,2), + source VARCHAR(20) DEFAULT 'csv', + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_nutrition_profile_date ON nutrition_log(profile_id, date DESC); + +-- ── Activity Log ──────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS activity_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + start_time TIME, + end_time TIME, + activity_type VARCHAR(50) NOT NULL, + duration_min NUMERIC(6,2), + kcal_active NUMERIC(7,2), + kcal_resting NUMERIC(7,2), + hr_avg NUMERIC(5,2), + hr_max NUMERIC(5,2), + distance_km NUMERIC(7,2), + rpe INTEGER CHECK (rpe >= 1 AND rpe <= 10), + source VARCHAR(20) DEFAULT 'manual', + notes TEXT, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_activity_profile_date ON activity_log(profile_id, date DESC); + +-- ── Photos ────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS photos ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE, + path TEXT NOT NULL, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_photos_profile_date ON photos(profile_id, date DESC); + +-- ================================================================ +-- AI TABLES +-- ================================================================ + +-- ── AI Insights ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ai_insights ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + scope VARCHAR(50) NOT NULL, + content TEXT NOT NULL, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ai_insights_profile_scope ON ai_insights(profile_id, scope, created DESC); + +-- ── AI Prompts ────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ai_prompts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + template TEXT NOT NULL, + active BOOLEAN DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ai_prompts_slug ON ai_prompts(slug); +CREATE INDEX IF NOT EXISTS idx_ai_prompts_active_sort ON ai_prompts(active, sort_order); + +-- ================================================================ +-- TRIGGERS +-- ================================================================ + +-- Auto-update timestamp trigger for profiles +CREATE OR REPLACE FUNCTION update_updated_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER IF NOT EXISTS trigger_profiles_updated + BEFORE UPDATE ON profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_timestamp(); + +CREATE TRIGGER IF NOT EXISTS trigger_ai_prompts_updated + BEFORE UPDATE ON ai_prompts + FOR EACH ROW + EXECUTE FUNCTION update_updated_timestamp(); + +-- ================================================================ +-- COMMENTS (Documentation) +-- ================================================================ + +COMMENT ON TABLE profiles IS 'User profiles with auth, permissions, and tier system'; +COMMENT ON TABLE sessions IS 'Active auth tokens'; +COMMENT ON TABLE ai_usage IS 'Daily AI call tracking per profile'; +COMMENT ON TABLE weight_log IS 'Weight measurements'; +COMMENT ON TABLE circumference_log IS 'Body circumference measurements (8 points)'; +COMMENT ON TABLE caliper_log IS 'Skinfold measurements with body fat calculations'; +COMMENT ON TABLE nutrition_log IS 'Daily nutrition intake (calories + macros)'; +COMMENT ON TABLE activity_log IS 'Training sessions and activities'; +COMMENT ON TABLE photos IS 'Progress photos'; +COMMENT ON TABLE ai_insights IS 'AI-generated analysis results'; +COMMENT ON TABLE ai_prompts IS 'Configurable AI prompt templates'; + +COMMENT ON COLUMN profiles.tier IS 'Subscription tier: free, basic, premium, selfhosted'; +COMMENT ON COLUMN profiles.trial_ends_at IS 'Trial expiration timestamp (14 days from registration)'; +COMMENT ON COLUMN profiles.tier_expires_at IS 'Paid tier expiration timestamp'; +COMMENT ON COLUMN profiles.invited_by IS 'Profile ID of inviter (for beta invitations)'; diff --git a/backend/startup.sh b/backend/startup.sh new file mode 100644 index 0000000..54bd626 --- /dev/null +++ b/backend/startup.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -e + +echo "═══════════════════════════════════════════════════════════" +echo "MITAI JINKENDO - Backend Startup (v9b)" +echo "═══════════════════════════════════════════════════════════" + +# ── PostgreSQL Connection Check ─────────────────────────────── +echo "" +echo "Checking PostgreSQL connection..." + +MAX_RETRIES=30 +RETRY_COUNT=0 + +until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "✗ PostgreSQL not ready after ${MAX_RETRIES} attempts" + echo " Exiting..." + exit 1 + fi + echo " Waiting for PostgreSQL... (attempt $RETRY_COUNT/$MAX_RETRIES)" + sleep 2 +done + +echo "✓ PostgreSQL ready" + +# ── Schema Initialization ────────────────────────────────────── +echo "" +echo "Checking database schema..." + +# Check if profiles table exists +TABLE_EXISTS=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -tAc \ + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='profiles'") + +if [ "$TABLE_EXISTS" = "0" ]; then + echo " Schema not found, initializing..." + PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -f /app/schema.sql + echo "✓ Schema loaded from schema.sql" +else + echo "✓ Schema already exists" +fi + +# ── Auto-Migration (SQLite → PostgreSQL) ─────────────────────── +echo "" +echo "Checking for SQLite data migration..." + +SQLITE_DB="/app/data/bodytrack.db" +PROFILE_COUNT=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -tAc \ + "SELECT COUNT(*) FROM profiles") + +if [ -f "$SQLITE_DB" ] && [ "$PROFILE_COUNT" = "0" ]; then + echo " SQLite database found and PostgreSQL is empty" + echo " Starting automatic migration..." + python /app/migrate_to_postgres.py + echo "✓ Migration completed" +elif [ -f "$SQLITE_DB" ] && [ "$PROFILE_COUNT" != "0" ]; then + echo "⚠ SQLite DB exists but PostgreSQL already has $PROFILE_COUNT profiles" + echo " Skipping migration (already migrated)" +elif [ ! -f "$SQLITE_DB" ]; then + echo "✓ No SQLite database found (fresh install or already migrated)" +else + echo "✓ No migration needed" +fi + +# ── Start Application ────────────────────────────────────────── +echo "" +echo "═══════════════════════════════════════════════════════════" +echo "Starting FastAPI application..." +echo "═══════════════════════════════════════════════════════════" +echo "" + +exec uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index af0ee02..3d95cb1 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -1,24 +1,55 @@ services: + postgres-dev: + image: postgres:16-alpine + container_name: dev-mitai-postgres + restart: unless-stopped + environment: + POSTGRES_DB: mitai_dev + POSTGRES_USER: mitai_dev + POSTGRES_PASSWORD: dev_password_change_me + volumes: + - mitai_dev_postgres_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mitai_dev"] + interval: 10s + timeout: 5s + retries: 5 + backend: build: ./backend container_name: dev-mitai-api restart: unless-stopped ports: - "8099:8000" + depends_on: + postgres-dev: + condition: service_healthy volumes: - - bodytrack_bodytrack-data:/app/data - bodytrack_bodytrack-photos:/app/photos environment: + # Database + - DB_HOST=postgres-dev + - DB_PORT=5432 + - DB_NAME=mitai_dev + - DB_USER=mitai_dev + - DB_PASSWORD=dev_password_change_me + + # AI - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + + # Email - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM} + + # App - APP_URL=${APP_URL_DEV:-https://dev.mitai.jinkendo.de} - - DATA_DIR=/app/data - PHOTOS_DIR=/app/photos - ALLOWED_ORIGINS=${ALLOWED_ORIGINS_DEV:-*} - ENVIRONMENT=development @@ -33,7 +64,6 @@ services: - backend volumes: - bodytrack_bodytrack-data: - external: true + mitai_dev_postgres_data: bodytrack_bodytrack-photos: external: true diff --git a/docker-compose.yml b/docker-compose.yml index a588a84..4f5378c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,55 @@ services: + postgres: + image: postgres:16-alpine + container_name: mitai-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-mitai} + POSTGRES_USER: ${DB_USER:-mitai} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - mitai_postgres_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-mitai}"] + interval: 10s + timeout: 5s + retries: 5 + backend: build: ./backend container_name: mitai-api restart: unless-stopped ports: - "8002:8000" + depends_on: + postgres: + condition: service_healthy volumes: - - bodytrack_bodytrack-data:/app/data - bodytrack_bodytrack-photos:/app/photos environment: + # Database + - DB_HOST=${DB_HOST:-postgres} + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_NAME:-mitai} + - DB_USER=${DB_USER:-mitai} + - DB_PASSWORD=${DB_PASSWORD} + + # AI - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + + # Email - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM} + + # App - APP_URL=${APP_URL} - - DATA_DIR=/app/data - PHOTOS_DIR=/app/photos - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} - ENVIRONMENT=production @@ -33,7 +64,7 @@ services: - backend volumes: - bodytrack_bodytrack-data: - external: true + mitai_postgres_data: + name: mitai_postgres_data bodytrack_bodytrack-photos: external: true