diff --git a/.env.example b/.env.example index 0da4e89..6cc4aa5 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=mitaiDB-PostgreSQL-Neckar-strong # ── 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 15fc208..5442bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,33 +1,32 @@ # Mitai Jinkendo – Entwickler-Kontext für Claude Code ## Projekt-Übersicht -**Mitai Jinkendo** ist eine selbst-gehostete PWA für Körper-Tracking (Gewicht, Körperfett, Umfänge, Ernährung, Aktivität) mit KI-Auswertung. Teil der **Jinkendo**-App-Familie (人拳道 – Der menschliche Weg der Kampfkunst). +**Mitai Jinkendo** (身体 Jinkendo) ist eine selbst-gehostete PWA für Körper-Tracking (Gewicht, Körperfett, Umfänge, Ernährung, Aktivität) mit KI-Auswertung. Teil der **Jinkendo**-App-Familie (人拳道 – Der menschliche Weg der Kampfkunst). -**Produktfamilie:** body · fight · guard · train · mind (alle unter jinkendo.de) +**Produktfamilie:** mitai · miken · ikigai · shinkan · kenkou (alle unter jinkendo.de) ## Tech-Stack | Komponente | Technologie | Version | |-----------|-------------|---------| | Frontend | React 18 + Vite + PWA | Node 20 | | Backend | FastAPI (Python) | Python 3.12 | -| Datenbank | PostgreSQL | 16 (Ziel: v9) / SQLite (aktuell: v8) | +| Datenbank | PostgreSQL 16 (Alpine) | v9b | | Container | Docker + Docker Compose | - | -| Webserver | nginx (Reverse Proxy + HTTPS) | Alpine | -| Auth | Token-basiert (eigene Impl.) | - | +| Webserver | nginx (Reverse Proxy) | Alpine | +| Auth | Token-basiert + bcrypt | - | | KI | OpenRouter API (claude-sonnet-4) | - | ## Ports -| Service | Intern | Extern (Dev) | -|---------|--------|-------------| -| Frontend | 80 (nginx) | 3002 | -| Backend | 8000 (uvicorn) | 8002 | -| PostgreSQL | 5432 | nicht exponiert | +| Service | Prod | Dev | +|---------|------|-----| +| Frontend | 3002 | 3099 | +| Backend | 8002 | 8099 | ## Verzeichnisstruktur ``` mitai-jinkendo/ ├── backend/ -│ ├── main.py # FastAPI App, alle Endpoints +│ ├── main.py # FastAPI App, alle Endpoints (~2000 Zeilen) │ ├── requirements.txt │ └── Dockerfile ├── frontend/ @@ -38,113 +37,159 @@ mitai-jinkendo/ │ │ │ ├── AuthContext.jsx # Session, Login, Logout │ │ │ └── ProfileContext.jsx # Aktives Profil │ │ ├── pages/ # Alle Screens -│ │ ├── utils/ -│ │ │ ├── api.js # Alle API-Calls (injiziert Token + ProfileId) -│ │ │ ├── calc.js # Körperfett-Formeln -│ │ │ ├── interpret.js # Regelbasierte Auswertung -│ │ │ ├── Markdown.jsx # Eigener MD-Renderer -│ │ │ └── guideData.js # Messanleitungen -│ │ └── main.jsx -│ ├── public/ # Icons (Jinkendo Ensō-Logo) -│ ├── index.html -│ ├── vite.config.js -│ └── Dockerfile -├── nginx/ -│ └── nginx.conf -├── docker-compose.yml # Produktion -├── docker-compose.dev.yml # Entwicklung (Hot-Reload) -├── .env.example -└── CLAUDE.md # Diese Datei +│ │ └── utils/ +│ │ ├── api.js # Alle API-Calls (injiziert Token automatisch) +│ │ ├── calc.js # Körperfett-Formeln +│ │ ├── interpret.js # Regelbasierte Auswertung +│ │ ├── Markdown.jsx # Eigener MD-Renderer +│ │ └── guideData.js # Messanleitungen +│ └── public/ # Icons (Jinkendo Ensō-Logo) +├── .gitea/workflows/ +│ ├── deploy-prod.yml # Auto-Deploy bei Push auf main +│ ├── deploy-dev.yml # Auto-Deploy bei Push auf develop +│ └── test.yml # Build-Test bei jedem Push +├── docker-compose.yml # Produktion (Ports 3002/8002) +├── docker-compose.dev-env.yml # Development (Ports 3099/8099) +└── CLAUDE.md # Diese Datei ``` -## Aktuelle Version: v8 +## Aktuelle Version: v9b + ### Was implementiert ist: -- ✅ Multi-User mit PIN/Passwort-Auth + Token-Sessions +- ✅ Multi-User mit E-Mail + Passwort Login (bcrypt) +- ✅ Auth-Middleware auf ALLE Endpoints (60+ Endpoints geschützt) +- ✅ Rate Limiting (Login: 5/min, Reset: 3/min) +- ✅ CORS konfigurierbar via ALLOWED_ORIGINS in .env - ✅ Admin/User Rollen, KI-Limits, Export-Berechtigungen - ✅ Gewicht, Umfänge, Caliper (4 Formeln), Ernährung, Aktivität - ✅ FDDB CSV-Import (Ernährung), Apple Health CSV-Import (Aktivität) - ✅ KI-Analyse: 6 Einzel-Prompts + 3-stufige Pipeline (parallel) -- ✅ Konfigurierbare Prompts mit Template-Variablen +- ✅ Konfigurierbare Prompts mit Template-Variablen (Admin kann bearbeiten) - ✅ Verlauf mit 5 Tabs + Zeitraumfilter + KI pro Sektion - ✅ Dashboard mit Kennzahlen, Zielfortschritt, Combo-Chart - ✅ Assistent-Modus (Schritt-für-Schritt Messung) -- ✅ PWA (iPhone Home Screen), Jinkendo-Icon +- ✅ PWA (iPhone Home Screen), Jinkendo Ensō-Logo - ✅ E-Mail (SMTP) für Password-Recovery -- ✅ Admin-Panel: User verwalten, KI-Limits, E-Mail-Test +- ✅ Admin-Panel: User verwalten, KI-Limits, E-Mail-Test, PIN/Email setzen +- ✅ Multi-Environment: Prod (mitai.jinkendo.de) + Dev (dev.mitai.jinkendo.de) +- ✅ Gitea CI/CD mit Auto-Deploy auf Raspberry Pi 5 +- ✅ PostgreSQL 16 Migration (vollständig von SQLite migriert) +- ✅ Export: CSV, JSON, ZIP (mit Fotos) +- ✅ Automatische SQLite→PostgreSQL Migration bei Container-Start -### Was in v9 kommt: -- 🔲 PostgreSQL Migration (aktuell: SQLite) -- 🔲 Auth-Middleware auf ALLE Endpoints -- 🔲 bcrypt statt SHA256 -- 🔲 Rate Limiting -- 🔲 CORS auf Domain beschränken +### Was in v9c kommt: - 🔲 Selbst-Registrierung mit E-Mail-Bestätigung - 🔲 Freemium Tier-System (free/basic/premium/selfhosted) -- 🔲 Login via E-Mail statt Profil-Liste -- 🔲 nginx + Let's Encrypt +- 🔲 14-Tage Trial automatisch +- 🔲 Einladungslinks für Beta-Nutzer +- 🔲 Admin kann Tiers manuell setzen -## Datenbank-Schema (SQLite, v8) +### Was in v9d kommt: +- 🔲 OAuth2-Grundgerüst für Fitness-Connectoren +- 🔲 Strava Connector +- 🔲 Withings Connector (Waage) +- 🔲 Garmin Connector + +## Deployment + +### Infrastruktur +``` +Internet → privat.stommer.com (Fritz!Box DynDNS) + → Synology NAS (Reverse Proxy + Let's Encrypt) + → Raspberry Pi 5 (192.168.2.49, Docker) +``` + +### Git Workflow +``` +develop branch → Auto-Deploy → dev.mitai.jinkendo.de (Port 3099/8099) +main branch → Auto-Deploy → mitai.jinkendo.de (Port 3002/8002) +``` + +### Deployment-Befehle (manuell falls nötig) +```bash +# Prod +cd /home/lars/docker/bodytrack +docker compose -f docker-compose.yml build --no-cache +docker compose -f docker-compose.yml up -d + +# Dev +cd /home/lars/docker/bodytrack-dev +docker compose -f docker-compose.dev-env.yml build --no-cache +docker compose -f docker-compose.dev-env.yml up -d +``` + +## Datenbank-Schema (PostgreSQL 16, v9b) ### Wichtige Tabellen: -- `profiles` – Nutzer mit Auth (role, pin_hash, auth_type, ai_enabled, export_enabled) +- `profiles` – Nutzer (role, pin_hash/bcrypt, email, auth_type, ai_enabled, tier) - `sessions` – Auth-Tokens mit Ablaufdatum - `weight_log` – Gewichtseinträge (profile_id, date, weight) - `circumference_log` – 8 Umfangspunkte - `caliper_log` – Hautfaltenmessung, 4 Methoden - `nutrition_log` – Kalorien + Makros (aus FDDB-CSV) - `activity_log` – Training (aus Apple Health oder manuell) +- `photos` – Progress Photos - `ai_insights` – KI-Auswertungen (scope = prompt-slug) -- `ai_prompts` – Konfigurierbare Prompts mit Templates +- `ai_prompts` – Konfigurierbare Prompts mit Templates (11 Prompts) - `ai_usage` – KI-Calls pro Tag pro Profil -## Auth-Flow (aktuell v8) +**Schema-Datei:** `backend/schema.sql` (vollständiges PostgreSQL-Schema) +**Migration-Script:** `backend/migrate_to_postgres.py` (SQLite→PostgreSQL, automatisch) + +## Auth-Flow (v9b) ``` -Login-Screen → Profil-Liste → PIN/Passwort → Token im localStorage +Login-Screen → E-Mail + Passwort → Token im localStorage Token → X-Auth-Token Header → Backend require_auth() Profile-Id → aus Session (nicht aus Header!) +SHA256 Passwörter → automatisch zu bcrypt migriert beim Login ``` ## API-Konventionen - Alle Endpoints: `/api/...` - Auth-Header: `X-Auth-Token: ` -- Profile-Header: `X-Profile-Id: ` (nur wo noch nicht migriert) - Responses: immer JSON - Fehler: `{"detail": "Fehlermeldung"}` +- Rate Limit überschritten: HTTP 429 ## Umgebungsvariablen (.env) ``` -OPENROUTER_API_KEY= # KI-Calls +# Database (PostgreSQL) +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=mitai_prod +DB_USER=mitai_prod +DB_PASSWORD= # REQUIRED + +# AI +OPENROUTER_API_KEY= # KI-Calls (optional, alternativ ANTHROPIC_API_KEY) OPENROUTER_MODEL=anthropic/claude-sonnet-4 -ANTHROPIC_API_KEY= # Alternative zu OpenRouter -SMTP_HOST= # E-Mail +ANTHROPIC_API_KEY= # Direkte Anthropic API (optional) + +# Email +SMTP_HOST= # E-Mail (für Recovery) SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM= -APP_URL= # Für Links in E-Mails -DATA_DIR=/app/data # SQLite-Pfad (v8) -PHOTOS_DIR=/app/photos -# v9 (PostgreSQL): -DATABASE_URL=postgresql://jinkendo:password@db/jinkendo -DB_PASSWORD= -``` -## Deployment (aktuell) -```bash -# Heimserver (Raspberry Pi 5, lars@raspberrypi5) -cd /home/lars/docker/bodytrack -docker compose build --no-cache [frontend|backend] -docker compose up -d -docker logs mitai-api --tail 30 +# App +APP_URL=https://mitai.jinkendo.de +ALLOWED_ORIGINS=https://mitai.jinkendo.de +DATA_DIR=/app/data +PHOTOS_DIR=/app/photos +ENVIRONMENT=production ``` ## Wichtige Hinweise für Claude Code -1. **Ports immer 3002/8002** – nie ändern +1. **Ports immer 3002/8002 (Prod) oder 3099/8099 (Dev)** – nie ändern 2. **npm install** (nicht npm ci) – kein package-lock.json vorhanden -3. **SQLite safe_alters** – neue Spalten immer via _safe_alters() hinzufügen +3. **PostgreSQL-Migrations** – Schema-Änderungen in `backend/schema.sql`, dann Container neu bauen 4. **Pipeline-Prompts** haben slug-Prefix `pipeline_` – nie als Einzelanalyse zeigen -5. **dayjs.week()** braucht Plugin – stattdessen native JS Wochenberechnung -6. **useNavigate()** nur in React-Komponenten (Großbuchstabe), nicht in Helper-Functions -7. **Bar fill=function** in Recharts nicht unterstützt – nur statische Farben +5. **dayjs.week()** braucht Plugin – stattdessen native JS ISO-Wochenberechnung +6. **useNavigate()** nur in React-Komponenten, nicht in Helper-Functions +7. **api.js nutzen** für alle API-Calls – injiziert Token automatisch +8. **bcrypt** für alle neuen Passwort-Operationen verwenden +9. **session=Depends(require_auth)** als separater Parameter – nie in Header() einbetten +10. **RealDictCursor verwenden** – `get_cursor(conn)` statt `conn.cursor()` für dict-like row access ## Code-Style - React: Functional Components, Hooks @@ -152,3 +197,453 @@ docker logs mitai-api --tail 30 - API-Calls: immer über `api.js` (injiziert Token automatisch) - Kein TypeScript (bewusst, für Einfachheit) - Python: keine Type-Hints Pflicht, aber bei neuen Funktionen erwünscht + +## Design-System + +### Farben (CSS-Variablen) +```css +--accent: #1D9E75 /* Jinkendo Grün – Buttons, Links, Akzente */ +--accent-dark: #085041 /* Dunkelgrün – Icon-Hintergrund, Header */ +--accent-light: #E1F5EE /* Hellgrün – Hintergründe, Badges */ +--bg: /* Seitenhintergrund (hell/dunkel auto) */ +--surface: /* Card-Hintergrund */ +--surface2: /* Sekundäre Fläche */ +--border: /* Rahmen */ +--text1: /* Primärer Text */ +--text2: /* Sekundärer Text */ +--text3: /* Muted Text, Labels */ +--danger: #D85A30 /* Fehler, Warnungen */ +``` + +### CSS-Klassen +```css +.card /* Weißer Container, border-radius 12px, box-shadow */ +.btn /* Basis-Button */ +.btn-primary /* Grüner Button (#1D9E75) */ +.btn-secondary /* Grauer Button */ +.btn-full /* 100% Breite */ +.form-input /* Eingabefeld, volle Breite */ +.form-label /* Feldbezeichnung, klein, uppercase */ +.form-row /* Label + Input + Unit nebeneinander */ +.form-unit /* Einheit rechts (kg, cm, etc.) */ +.section-gap /* margin-bottom zwischen Sektionen */ +.spinner /* Ladekreis, animiert */ +``` + +### Abstände & Größen +``` +Seiten-Padding: 16px seitlich +Card-Padding: 16-20px +Border-Radius: 12px (Cards), 8px (Buttons/Inputs), 50% (Avatare) +Icon-Größe: 16-20px inline, 24px standalone +Font-Größe: 12px (Labels), 14px (Body), 16-18px (Subtitel), 20-24px (Titel) +Font-Weight: 400 (normal), 600 (semi-bold), 700 (bold) +Bottom-Padding: 80px (für Mobile-Navigation) +``` + +### Komponenten-Muster + +**Titelzeile einer Seite:** +```jsx +
+
+ Seitentitel +
+ +
+``` + +**Ladezustand:** +```jsx +if (loading) return ( +
+
+
+) +``` + +**Fehlerzustand:** +```jsx +if (error) return ( +
+ {error} +
+) +``` + +**Leerer Zustand:** +```jsx +{items.length === 0 && ( +
+
📭
+
Noch keine Einträge
+
+)} +``` + +**Metric Card:** +```jsx +
+
LABEL
+
+ {value} +
+
Einheit
+
+``` + +### Jinkendo Logo-System +``` +Grundelement: Ensō-Kreis (offen, Lücke 4-5 Uhr) +Farbe Ensō: #1D9E75 +Hintergrund: #085041 (dunkelgrün) +Kern-Symbol: #5DCAA5 (mintgrün) +Wortmarke: Jin(light) + ken(bold #1D9E75) + do(light) +``` + +### Verfügbare Custom Commands +``` +/deploy → Commit + Push vorbereiten +/merge-to-prod → develop → main mergen +/test → Manuelle Tests durchführen +/new-feature → Neues Feature-Template +/ui-component → Neue Komponente erstellen +/ui-page → Neue Seite erstellen +/fix-bug → Bug analysieren und beheben +/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: + +``` + +### PostgreSQL Boolean-Syntax +```python +# ❌ Falsch (SQLite-Syntax): +cur.execute("SELECT * FROM ai_prompts WHERE active=1") + +# ✅ Richtig (PostgreSQL): +cur.execute("SELECT * FROM ai_prompts WHERE active=true") +``` + +### RealDictCursor für dict-like row access +```python +# ❌ Falsch: +cur = conn.cursor() +cur.execute("SELECT COUNT(*) FROM weight_log") +count = cur.fetchone()[0] # Tuple index + +# ✅ Richtig: +cur = get_cursor(conn) # Returns RealDictCursor +cur.execute("SELECT COUNT(*) as count FROM weight_log") +count = cur.fetchone()['count'] # Dict key +``` + +## v9b Migration – Lessons Learned + +### PostgreSQL Migration (SQLite → PostgreSQL) +**Problem:** Docker Build hing 30+ Minuten bei `apt-get install postgresql-client` +**Lösung:** Alle apt-get dependencies entfernt, reine Python-Lösung mit psycopg2-binary + +**Problem:** Leere date-Strings (`''`) führten zu PostgreSQL-Fehlern +**Lösung:** Migration-Script konvertiert leere Strings zu NULL für DATE-Spalten + +**Problem:** Boolean-Felder (SQLite INTEGER 0/1 vs PostgreSQL BOOLEAN) +**Lösung:** Migration konvertiert automatisch, Backend nutzt `active=true` statt `active=1` + +### API Endpoint Consistency (März 2026) +**Problem:** 11 kritische Endpoint-Mismatches zwischen Frontend und Backend gefunden +**Gelöst:** +- AI-Endpoints konsistent: `/api/insights/run/{slug}`, `/api/insights/pipeline` +- Password-Reset: `/api/auth/forgot-password`, `/api/auth/reset-password` +- Admin-Endpoints: `/permissions`, `/email`, `/pin` Sub-Routes +- Export: JSON + ZIP Endpoints hinzugefügt +- Prompt-Bearbeitung: PUT-Endpoint für Admins + +**Tool:** Vollständiger Audit via Explore-Agent empfohlen bei größeren Änderungen + +## Export/Import Spezifikation (v9c) + +### ZIP-Export Struktur +``` +mitai-export-{name}-{YYYY-MM-DD}.zip +├── README.txt ← Erklärung des Formats + Versionsnummer +├── profile.json ← Profildaten (ohne Passwort-Hash) +├── data/ +│ ├── weight.csv ← Gewichtsverlauf +│ ├── circumferences.csv ← Umfänge (8 Messpunkte) +│ ├── caliper.csv ← Caliper-Messungen +│ ├── nutrition.csv ← Ernährungsdaten +│ └── activity.csv ← Aktivitäten +├── insights/ +│ └── ai_insights.json ← KI-Auswertungen (alle gespeicherten) +└── photos/ + ├── {date}_{id}.jpg ← Progress-Fotos + └── ... +``` + +### CSV Format (alle Dateien) +``` +- Trennzeichen: Semikolon (;) – Excel/LibreOffice kompatibel +- Encoding: UTF-8 mit BOM (für Windows Excel) +- Datumsformat: YYYY-MM-DD +- Dezimaltrennzeichen: Punkt (.) +- Erste Zeile: Header +- Nullwerte: leer (nicht "null" oder "NULL") +``` + +### weight.csv Spalten +``` +id;date;weight;note;source;created +``` + +### circumferences.csv Spalten +``` +id;date;waist;hip;chest;neck;upper_arm;thigh;calf;forearm;note;created +``` + +### caliper.csv Spalten +``` +id;date;chest;abdomen;thigh;tricep;subscapular;suprailiac;midaxillary;method;bf_percent;note;created +``` + +### nutrition.csv Spalten +``` +id;date;meal_name;kcal;protein;fat;carbs;fiber;note;source;created +``` + +### activity.csv Spalten +``` +id;date;name;type;duration_min;kcal;heart_rate_avg;heart_rate_max;distance_km;note;source;created +``` + +### profile.json Struktur +```json +{ + "export_version": "2", + "export_date": "2026-03-18", + "app": "Mitai Jinkendo", + "profile": { + "name": "Lars", + "email": "lars@stommer.com", + "sex": "m", + "height": 178, + "birth_year": 1980, + "goal_weight": 82, + "goal_bf_pct": 14, + "avatar_color": "#1D9E75", + "auth_type": "password", + "session_days": 30, + "ai_enabled": true, + "tier": "selfhosted" + }, + "stats": { + "weight_entries": 150, + "nutrition_entries": 300, + "activity_entries": 45, + "photos": 12 + } +} +``` + +### ai_insights.json Struktur +```json +[ + { + "id": "uuid", + "scope": "gesamt", + "created": "2026-03-18T10:00:00", + "result": "KI-Analyse Text..." + } +] +``` + +### README.txt Inhalt +``` +Mitai Jinkendo – Datenexport +Version: 2 +Exportiert am: YYYY-MM-DD +Profil: {name} + +Inhalt: +- profile.json: Profildaten und Einstellungen +- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8) +- insights/: KI-Auswertungen (JSON) +- photos/: Progress-Fotos (JPEG) + +Import: +Dieser Export kann in Mitai Jinkendo unter +Einstellungen → Import → "Mitai Backup importieren" +wieder eingespielt werden. + +Format-Version 2 (ab v9b): +Alle CSV-Dateien sind UTF-8 mit BOM kodiert. +Trennzeichen: Semikolon (;) +Datumsformat: YYYY-MM-DD +``` + +### Import-Funktion (zu implementieren) +**Endpoint:** `POST /api/import/zip` +**Verhalten:** +- Akzeptiert ZIP-Datei (multipart/form-data) +- Erkennt export_version aus profile.json +- Importiert nur fehlende Einträge (kein Duplikat) +- Fotos werden nicht überschrieben falls bereits vorhanden +- Gibt Zusammenfassung zurück: wie viele Einträge je Kategorie importiert +- Bei Fehler: vollständiger Rollback (alle oder nichts) + +**Duplikat-Erkennung:** +```python +# INSERT ... ON CONFLICT (profile_id, date) DO NOTHING +# weight: UNIQUE (profile_id, date) +# nutrition: UNIQUE (profile_id, date, meal_name) +# activity: UNIQUE (profile_id, date, name) +# caliper: UNIQUE (profile_id, date) +# circumferences: UNIQUE (profile_id, date) +``` + +**Frontend:** Neuer Button in SettingsPage: +``` +[ZIP exportieren] [JSON exportieren] [Backup importieren] +``` diff --git a/backend/Dockerfile b/backend/Dockerfile index a3745aa..e0b5705 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,21 @@ FROM python:3.12-slim + +# No system packages needed - we use Python (psycopg2-binary) for PostgreSQL checks + 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/db_init.py b/backend/db_init.py new file mode 100644 index 0000000..67283d2 --- /dev/null +++ b/backend/db_init.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Database initialization script for PostgreSQL. +Replaces psql commands in startup.sh with pure Python. +""" +import os +import sys +import time +import psycopg2 +from psycopg2 import OperationalError + +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "mitai_dev") +DB_USER = os.getenv("DB_USER", "mitai_dev") +DB_PASSWORD = os.getenv("DB_PASSWORD", "") + +def get_connection(): + """Get PostgreSQL connection.""" + return psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + +def wait_for_postgres(max_retries=30): + """Wait for PostgreSQL to be ready.""" + print("\nChecking PostgreSQL connection...") + for i in range(1, max_retries + 1): + try: + conn = get_connection() + conn.close() + print("✓ PostgreSQL ready") + return True + except OperationalError: + print(f" Waiting for PostgreSQL... (attempt {i}/{max_retries})") + time.sleep(2) + + print(f"✗ PostgreSQL not ready after {max_retries} attempts") + return False + +def check_table_exists(table_name="profiles"): + """Check if a table exists.""" + try: + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema='public' AND table_name=%s + """, (table_name,)) + count = cur.fetchone()[0] + cur.close() + conn.close() + return count > 0 + except Exception as e: + print(f"Error checking table: {e}") + return False + +def load_schema(schema_file="/app/schema.sql"): + """Load schema from SQL file.""" + try: + with open(schema_file, 'r') as f: + schema_sql = f.read() + + conn = get_connection() + cur = conn.cursor() + cur.execute(schema_sql) + conn.commit() + cur.close() + conn.close() + print("✓ Schema loaded from schema.sql") + return True + except Exception as e: + print(f"✗ Error loading schema: {e}") + return False + +def get_profile_count(): + """Get number of profiles in database.""" + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM profiles") + count = cur.fetchone()[0] + cur.close() + conn.close() + return count + except Exception as e: + print(f"Error getting profile count: {e}") + return -1 + +if __name__ == "__main__": + print("═══════════════════════════════════════════════════════════") + print("MITAI JINKENDO - Database Initialization (v9b)") + print("═══════════════════════════════════════════════════════════") + + # Wait for PostgreSQL + if not wait_for_postgres(): + sys.exit(1) + + # Check schema + print("\nChecking database schema...") + if not check_table_exists("profiles"): + print(" Schema not found, initializing...") + if not load_schema(): + sys.exit(1) + else: + print("✓ Schema already exists") + + # Check for migration + print("\nChecking for SQLite data migration...") + sqlite_db = "/app/data/bodytrack.db" + profile_count = get_profile_count() + + if os.path.exists(sqlite_db) and profile_count == 0: + print(" SQLite database found and PostgreSQL is empty") + print(" Starting automatic migration...") + # Import and run migration + try: + from migrate_to_postgres import main as migrate + migrate() + except Exception as e: + print(f"✗ Migration failed: {e}") + sys.exit(1) + elif os.path.exists(sqlite_db) and profile_count > 0: + print(f"⚠ SQLite DB exists but PostgreSQL already has {profile_count} profiles") + print(" Skipping migration (already migrated)") + elif not os.path.exists(sqlite_db): + print("✓ No SQLite database found (fresh install or already migrated)") + else: + print("✓ No migration needed") + + print("\n✓ Database initialization complete") diff --git a/backend/main.py b/backend/main.py index 8876ee7..730665f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,22 +1,24 @@ -import os, csv, io, uuid +import os, csv, io, uuid, json, zipfile from pathlib import Path from typing import Optional from datetime import datetime +from decimal import Decimal from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Depends from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, FileResponse +from fastapi.responses import StreamingResponse, FileResponse, Response 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, get_cursor, 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 +38,36 @@ 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'] +@app.on_event("startup") +async def startup_event(): + """Run migrations and initialization on startup.""" + init_db() + def init_db(): + """Initialize database - Schema is loaded by startup.sh""" + # Schema loading and migration handled by startup.sh + # This function kept for backwards compatibility + + # Ensure "pipeline" master prompt exists 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() + cur = get_cursor(conn) + cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'") + if cur.fetchone()['count'] == 0: + cur.execute(""" + INSERT INTO ai_prompts (slug, name, description, template, active, sort_order) + VALUES ( + 'pipeline', + 'Mehrstufige Gesamtanalyse', + 'Master-Schalter für die gesamte Pipeline. Deaktiviere diese Analyse, um die Pipeline komplett zu verstecken.', + 'PIPELINE_MASTER', + true, + -10 + ) + """) + conn.commit() + print("✓ Pipeline master prompt created") # ── Helper: get profile_id from header ─────────────────────────────────────── def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str: @@ -507,7 +75,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 = get_cursor(conn) + cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1") + row = cur.fetchone() if row: return row['id'] raise HTTPException(400, "Kein Profil gefunden") @@ -588,11 +158,13 @@ def make_token() -> str: def get_session(token: str): if not token: return None with get_db() as conn: - row = conn.execute( + cur = get_cursor(conn) + 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)): @@ -600,6 +172,12 @@ def require_auth(x_auth_token: Optional[str]=Header(default=None)): if not session: raise HTTPException(401, "Nicht eingeloggt") return session +def require_auth_flexible(x_auth_token: Optional[str]=Header(default=None), token: Optional[str]=Query(default=None)): + """Auth via header OR query parameter (for tags).""" + session = get_session(x_auth_token or token) + if not session: raise HTTPException(401, "Nicht eingeloggt") + return session + def require_admin(x_auth_token: Optional[str]=Header(default=None)): session = get_session(x_auth_token) if not session: raise HTTPException(401, "Nicht eingeloggt") @@ -609,24 +187,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 = get_cursor(conn) + cur.execute("SELECT * FROM profiles ORDER BY created") + rows = cur.fetchall() return [r2d(r) for r in rows] @app.post("/api/profiles") def create_profile(p: ProfileCreate, session=Depends(require_auth)): pid = str(uuid.uuid4()) with get_db() as conn: - 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 = get_cursor(conn) + cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""", (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct)) - conn.commit() with get_db() as conn: - return r2d(conn.execute("SELECT * FROM profiles WHERE id=?", (pid,)).fetchone()) + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + return r2d(cur.fetchone()) @app.get("/api/profiles/{pid}") def get_profile(pid: str, session=Depends(require_auth)): with get_db() as conn: - row = conn.execute("SELECT * FROM profiles WHERE id=?", (pid,)).fetchone() + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + row = cur.fetchone() if not row: raise HTTPException(404, "Profil nicht gefunden") return r2d(row) @@ -635,80 +219,88 @@ 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 = get_cursor(conn) + 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 = get_cursor(conn) + cur.execute("SELECT COUNT(*) as count FROM profiles") + count = cur.fetchone()['count'] if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden") for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']: - 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 = get_cursor(conn) + cur.execute( + "SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) + return [r2d(r) for r in cur.fetchall()] @app.post("/api/weight") def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - ex = conn.execute("SELECT id FROM weight_log WHERE profile_id=? AND date=?", (pid,e.date)).fetchone() + cur = get_cursor(conn) + cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date)) + ex = cur.fetchone() if ex: - 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 = get_cursor(conn) + cur.execute("UPDATE weight_log SET date=%s,weight=%s,note=%s WHERE id=%s AND profile_id=%s", + (e.date,e.weight,e.note,wid,pid)) return {"id":wid} @app.delete("/api/weight/{wid}") def delete_weight(wid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - conn.execute("DELETE FROM weight_log WHERE id=? AND profile_id=?", (wid,pid)); conn.commit() + cur = get_cursor(conn) + cur.execute("DELETE FROM weight_log WHERE id=%s AND profile_id=%s", (wid,pid)) return {"ok":True} @app.get("/api/weight/stats") def weight_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - rows = conn.execute("SELECT date,weight FROM weight_log WHERE profile_id=? ORDER BY date DESC LIMIT 90", (pid,)).fetchall() + cur = get_cursor(conn) + cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,)) + rows = cur.fetchall() if not rows: return {"count":0,"latest":None,"prev":None,"min":None,"max":None,"avg_7d":None} - w=[r['weight'] for r in rows] - return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":rows[0]['weight']}, - "prev":{"date":rows[1]['date'],"weight":rows[1]['weight']} if len(rows)>1 else None, + w=[float(r['weight']) for r in rows] + return {"count":len(rows),"latest":{"date":rows[0]['date'],"weight":float(rows[0]['weight'])}, + "prev":{"date":rows[1]['date'],"weight":float(rows[1]['weight'])} if len(rows)>1 else None, "min":min(w),"max":max(w),"avg_7d":round(sum(w[:7])/min(7,len(w)),2)} # ── Circumferences ──────────────────────────────────────────────────────────── @@ -716,28 +308,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 = get_cursor(conn) + cur.execute( + "SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) + return [r2d(r) for r in cur.fetchall()] @app.post("/api/circumferences") def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - ex = conn.execute("SELECT id FROM circumference_log WHERE profile_id=? AND date=?", (pid,e.date)).fetchone() + cur = get_cursor(conn) + cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date)) + ex = cur.fetchone() d = e.model_dump() if ex: eid = ex['id'] - sets = ', '.join(f"{k}=?" 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 +340,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 = get_cursor(conn) + cur.execute(f"UPDATE circumference_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", + list(d.values())+[eid,pid]) return {"id":eid} @app.delete("/api/circumferences/{eid}") def delete_circ(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - conn.execute("DELETE FROM circumference_log WHERE id=? AND profile_id=?", (eid,pid)); conn.commit() + cur = get_cursor(conn) + cur.execute("DELETE FROM circumference_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} # ── Caliper ─────────────────────────────────────────────────────────────────── @@ -761,30 +358,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 = get_cursor(conn) + cur.execute( + "SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) + return [r2d(r) for r in cur.fetchall()] @app.post("/api/caliper") def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - ex = conn.execute("SELECT id FROM caliper_log WHERE profile_id=? AND date=?", (pid,e.date)).fetchone() + cur = get_cursor(conn) + cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date)) + ex = cur.fetchone() d = e.model_dump() if ex: eid = ex['id'] - sets = ', '.join(f"{k}=?" 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 +392,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 = get_cursor(conn) + cur.execute(f"UPDATE caliper_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", + list(d.values())+[eid,pid]) return {"id":eid} @app.delete("/api/caliper/{eid}") def delete_caliper(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - conn.execute("DELETE FROM caliper_log WHERE id=? AND profile_id=?", (eid,pid)); conn.commit() + cur = get_cursor(conn) + cur.execute("DELETE FROM caliper_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} # ── Activity ────────────────────────────────────────────────────────────────── @@ -808,8 +410,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 = get_cursor(conn) + cur.execute( + "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC, start_time DESC LIMIT %s", (pid,limit)) + return [r2d(r) for r in cur.fetchall()] @app.post("/api/activity") def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): @@ -817,14 +421,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 = get_cursor(conn) + cur.execute("""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, hr_avg,hr_max,distance_km,rpe,source,notes,created) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,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,35 +436,40 @@ 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 = get_cursor(conn) + cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", + list(d.values())+[eid,pid]) return {"id":eid} @app.delete("/api/activity/{eid}") def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - conn.execute("DELETE FROM activity_log WHERE id=? AND profile_id=?", (eid,pid)); conn.commit() + cur = get_cursor(conn) + cur.execute("DELETE FROM activity_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} @app.get("/api/activity/stats") def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - rows = [r2d(r) for r in conn.execute( - "SELECT * FROM activity_log WHERE profile_id=? ORDER BY date DESC LIMIT 30", (pid,)).fetchall()] + cur = get_cursor(conn) + cur.execute( + "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) + rows = [r2d(r) for r in cur.fetchall()] if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}} - total_kcal=sum(r.get('kcal_active') or 0 for r in rows) - total_min=sum(r.get('duration_min') or 0 for r in rows) + total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows) + total_min=sum(float(r.get('duration_min') or 0) for r in rows) by_type={} for r in rows: t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0}) - by_type[t]['count']+=1; by_type[t]['kcal']+=r.get('kcal_active') or 0 - by_type[t]['min']+=r.get('duration_min') or 0 + by_type[t]['count']+=1 + by_type[t]['kcal']+=float(r.get('kcal_active') or 0) + by_type[t]['min']+=float(r.get('duration_min') or 0) return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type} @app.post("/api/activity/import-csv") -async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None)): +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 +479,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 = get_cursor(conn) for row in reader: wtype = row.get('Workout Type','').strip() start = row.get('Start','').strip() @@ -890,10 +500,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 +511,44 @@ 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 = get_cursor(conn) + cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", + (fid,pid,date,str(path))) return {"id":fid,"date":date} @app.get("/api/photos/{fid}") -def get_photo(fid: str): +def get_photo(fid: str, session: dict=Depends(require_auth_flexible)): + """Get photo by ID. Auth via header or query param (for tags).""" with get_db() as conn: - row = conn.execute("SELECT path FROM photos WHERE id=?", (fid,)).fetchone() - if not row: raise HTTPException(404) - return FileResponse(row['path']) + cur = get_cursor(conn) + cur.execute("SELECT path FROM photos WHERE id=%s", (fid,)) + row = cur.fetchone() + if not row: raise HTTPException(404, "Photo not found") + photo_path = Path(PHOTOS_DIR) / row['path'] + if not photo_path.exists(): + raise HTTPException(404, "Photo file not found") + return FileResponse(photo_path) @app.get("/api/photos") -def list_photos(x_profile_id: Optional[str]=Header(default=None)): +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 = get_cursor(conn) + cur.execute( + "SELECT * FROM photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,)) + return [r2d(r) for r in cur.fetchall()] # ── Nutrition ───────────────────────────────────────────────────────────────── def _pf(s): @@ -938,7 +556,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 +581,18 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona count+=1 inserted=0 with get_db() as conn: + cur = get_cursor(conn) for iso,vals in days.items(): kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1) carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1) - 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 +600,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 = get_cursor(conn) + cur.execute( + "SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT %s", (pid,limit)) + return [r2d(r) for r in cur.fetchall()] @app.get("/api/nutrition/correlations") def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - 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 = get_cursor(conn) + cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date",(pid,)) + nutr={r['date']:r2d(r) for r in cur.fetchall()} + cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,)) + wlog={r['date']:r['weight'] for r in cur.fetchall()} + cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,)) + cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date']) all_dates=sorted(set(list(nutr)+list(wlog))) mi,last_cal,cal_by_date=0,{},{} for d in all_dates: @@ -1000,11 +625,13 @@ def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), ses for d in all_dates: if d not in nutr and d not in wlog: continue row={'date':d} - if d in nutr: row.update({k:nutr[d][k] for k in ['kcal','protein_g','fat_g','carbs_g']}) - if d in wlog: row['weight']=wlog[d] + if d in nutr: row.update({k:float(nutr[d][k]) if nutr[d][k] is not None else None for k in ['kcal','protein_g','fat_g','carbs_g']}) + if d in wlog: row['weight']=float(wlog[d]) if d in cal_by_date: - row['lean_mass']=cal_by_date[d].get('lean_mass') - row['body_fat_pct']=cal_by_date[d].get('body_fat_pct') + lm = cal_by_date[d].get('lean_mass') + bf = cal_by_date[d].get('body_fat_pct') + row['lean_mass']=float(lm) if lm is not None else None + row['body_fat_pct']=float(bf) if bf is not None else None result.append(row) return result @@ -1012,7 +639,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 = get_cursor(conn) + 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: @@ -1021,7 +650,7 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N result=[] for wk in sorted(wm): en=wm[wk]; n=len(en) - def avg(k): return round(sum(e.get(k) or 0 for e in en)/n,1) + def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1) result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')}) return result @@ -1030,1048 +659,1347 @@ 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 = get_cursor(conn) + cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s",(pid,)) + weight_count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM circumference_log WHERE profile_id=%s",(pid,)) + circ_count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM caliper_log WHERE profile_id=%s",(pid,)) + caliper_count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM nutrition_log WHERE profile_id=%s",(pid,)) + nutrition_count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM activity_log WHERE profile_id=%s",(pid,)) + activity_count = cur.fetchone()['count'] return { - "weight_count": 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()] - - 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] - else: - nutr_summary="Keine Ernährungsdaten"; nutr_detail=[] - - 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) - 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] +# ── AI Insights ─────────────────────────────────────────────────────────────── +import httpx, json @app.get("/api/insights") -def list_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): +def get_all_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Get all AI insights for profile.""" pid = get_pid(x_profile_id) with get_db() as conn: - 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], - } - 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"}) - -# ── 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 - -@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() + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) + rows = cur.fetchall() return [r2d(r) for r in rows] -@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.""" +@app.get("/api/insights/latest") +def get_latest_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Get latest AI insights across all scopes.""" pid = get_pid(x_profile_id) - 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'] + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC LIMIT 10", (pid,)) + rows = cur.fetchall() + return [r2d(r) for r in rows] - 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()] +@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 = get_cursor(conn) + cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s AND scope=%s ORDER BY created DESC LIMIT 1", (pid,scope)) + row = cur.fetchone() + if not row: return None + return r2d(row) - # 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 '–', +@app.delete("/api/insights/{insight_id}") +def delete_insight_by_id(insight_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Delete a specific insight by ID.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM ai_insights WHERE id=%s AND profile_id=%s", (insight_id, pid)) + return {"ok":True} + +@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 = get_cursor(conn) + 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 = get_cursor(conn) + cur.execute("SELECT ai_enabled, ai_limit_day FROM profiles WHERE id=%s", (pid,)) + prof = cur.fetchone() + if not prof or not prof['ai_enabled']: + raise HTTPException(403, "KI ist für dieses Profil deaktiviert") + limit = prof['ai_limit_day'] + if limit is None: + return (True, None, 0) + today = datetime.now().date().isoformat() + cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today)) + usage = cur.fetchone() + used = usage['call_count'] if usage else 0 + if used >= limit: + raise HTTPException(429, f"Tägliches KI-Limit erreicht ({limit} Calls)") + return (True, limit, used) + +def inc_ai_usage(pid: str): + """Increment AI usage counter for today.""" + today = datetime.now().date().isoformat() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id, call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today)) + row = cur.fetchone() + if row: + cur.execute("UPDATE ai_usage SET call_count=%s WHERE id=%s", (row['call_count']+1, row['id'])) + else: + cur.execute("INSERT INTO ai_usage (id, profile_id, date, call_count) VALUES (%s,%s,%s,1)", + (str(uuid.uuid4()), pid, today)) + +def _get_profile_data(pid: str): + """Fetch all relevant data for AI analysis.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + prof = r2d(cur.fetchone()) + cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,)) + weight = [r2d(r) for r in cur.fetchall()] + cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) + circ = [r2d(r) for r in cur.fetchall()] + cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) + caliper = [r2d(r) for r in cur.fetchall()] + cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,)) + nutrition = [r2d(r) for r in cur.fetchall()] + cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,)) + activity = [r2d(r) for r in cur.fetchall()] + return { + "profile": prof, + "weight": weight, + "circumference": circ, + "caliper": caliper, + "nutrition": nutrition, + "activity": activity } - # 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)" +def _render_template(template: str, data: dict) -> str: + """Simple template variable replacement.""" + result = template + for k, v in data.items(): + result = result.replace(f"{{{{{k}}}}}", str(v) if v is not None else "") + return result + +def _prepare_template_vars(data: dict) -> dict: + """Prepare template variables from profile data.""" + prof = data['profile'] + weight = data['weight'] + circ = data['circumference'] + caliper = data['caliper'] + nutrition = data['nutrition'] + activity = data['activity'] + + vars = { + "name": prof.get('name', 'Nutzer'), + "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich", + "height": prof.get('height', 178), + "goal_weight": float(prof.get('goal_weight')) if prof.get('goal_weight') else "nicht gesetzt", + "goal_bf_pct": float(prof.get('goal_bf_pct')) if prof.get('goal_bf_pct') else "nicht gesetzt", + "weight_aktuell": float(weight[0]['weight']) if weight else "keine Daten", + "kf_aktuell": float(caliper[0]['body_fat_pct']) if caliper and caliper[0].get('body_fat_pct') else "unbekannt", + } + + # Calculate age from dob + if prof.get('dob'): + try: + from datetime import date + dob = datetime.strptime(prof['dob'], '%Y-%m-%d').date() + today = date.today() + age = today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day)) + vars['age'] = age + except: + vars['age'] = "unbekannt" else: - vars["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 = float(recent[0]['weight']) - float(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] + bf = float(c.get('body_fat_pct')) if c.get('body_fat_pct') else '?' + vars['caliper_summary'] = f"KF: {bf}%, Methode: {c.get('sf_method','?')}" + else: + vars['caliper_summary'] = "keine Daten" - # 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]}: {float(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(float(d.get('kcal',0) or 0) for d in nutrition) / n + avg_prot = sum(float(d.get('protein_g',0) or 0) for d in nutrition) / n + vars['nutrition_summary'] = f"{n} Tage, Ø {avg_kcal:.0f}kcal, {avg_prot:.0f}g Protein" + vars['nutrition_detail'] = vars['nutrition_summary'] + vars['nutrition_days'] = n + vars['kcal_avg'] = round(avg_kcal) + vars['protein_avg'] = round(avg_prot,1) + vars['fat_avg'] = round(sum(float(d.get('fat_g',0) or 0) for d in nutrition) / n,1) + vars['carb_avg'] = round(sum(float(d.get('carbs_g',0) or 0) for d in nutrition) / n,1) + else: + vars['nutrition_summary'] = "keine Daten" + vars['nutrition_detail'] = "keine Daten" + vars['nutrition_days'] = 0 + vars['kcal_avg'] = 0 + vars['protein_avg'] = 0 + vars['fat_avg'] = 0 + vars['carb_avg'] = 0 + + # Protein targets + w = weight[0]['weight'] if weight else prof.get('height',178) - 100 + w = float(w) # Convert Decimal to float for math operations + vars['protein_ziel_low'] = round(w * 1.6) + vars['protein_ziel_high'] = round(w * 2.2) # Activity summary - 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(float(a.get('kcal_active',0) or 0) for a in activity) + vars['activity_summary'] = f"{n} Trainings, {total_kcal:.0f}kcal gesamt" + vars['activity_detail'] = vars['activity_summary'] + vars['activity_kcal_summary'] = f"Ø {total_kcal/n:.0f}kcal/Training" + else: + vars['activity_summary'] = "keine Daten" + vars['activity_detail'] = "keine Daten" + vars['activity_kcal_summary'] = "keine Daten" - # ── Load pipeline prompts from DB ───────────────────────────────────── + return vars + +@app.post("/api/insights/run/{slug}") +async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Run AI analysis with specified prompt template.""" + pid = get_pid(x_profile_id) + check_ai_limit(pid) + + # Get prompt template with get_db() as conn: - p_rows = {r['slug']:r['template'] for r in conn.execute( - "SELECT slug,template FROM ai_prompts WHERE slug LIKE 'pipeline_%'" - ).fetchall()} + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=true", (slug,)) + prompt_row = cur.fetchone() + if not prompt_row: + raise HTTPException(404, f"Prompt '{slug}' nicht gefunden") - 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 = get_cursor(conn) + cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug)) + cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", + (str(uuid.uuid4()), pid, slug, content)) - 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/insights/pipeline") +async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Run 3-stage pipeline analysis.""" + pid = get_pid(x_profile_id) + check_ai_limit(pid) - # 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 = get_cursor(conn) + cur.execute("SELECT slug, template FROM ai_prompts WHERE slug LIKE 'pipeline_%' AND slug NOT IN ('pipeline_synthesis','pipeline_goals') AND active=true") + stage1_prompts = [r2d(r) for r in cur.fetchall()] - # ── 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 = get_cursor(conn) + cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=true") + synth_row = cur.fetchone() + if not synth_row: + raise HTTPException(500, "Pipeline synthesis prompt not found") + + synth_prompt = _render_template(synth_row['template'], vars) + + if ANTHROPIC_KEY: + import anthropic + client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=2000, + messages=[{"role": "user", "content": synth_prompt}] + ) + synthesis = response.content[0].text + elif OPENROUTER_KEY: + async with httpx.AsyncClient() as client: + resp = await client.post("https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {OPENROUTER_KEY}"}, + json={ + "model": OPENROUTER_MODEL, + "messages": [{"role": "user", "content": synth_prompt}], + "max_tokens": 2000 + }, + timeout=60.0 + ) + synthesis = resp.json()['choices'][0]['message']['content'] + else: + raise HTTPException(500, "Keine KI-API konfiguriert") + + # Stage 3: Goals (only if goals are set) + goals_text = None + prof = data['profile'] + if prof.get('goal_weight') or prof.get('goal_bf_pct'): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_goals' AND active=true") + goals_row = cur.fetchone() + if goals_row: + goals_prompt = _render_template(goals_row['template'], vars) + + if ANTHROPIC_KEY: + import anthropic + client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) + response = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=800, + messages=[{"role": "user", "content": goals_prompt}] + ) + goals_text = response.content[0].text + elif OPENROUTER_KEY: + async with httpx.AsyncClient() as client: + resp = await client.post("https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {OPENROUTER_KEY}"}, + json={ + "model": OPENROUTER_MODEL, + "messages": [{"role": "user", "content": goals_prompt}], + "max_tokens": 800 + }, + timeout=60.0 + ) + goals_text = resp.json()['choices'][0]['message']['content'] + + # Combine synthesis + goals + final_content = synthesis + if goals_text: + final_content += "\n\n" + goals_text + + # Save as 'gesamt' scope + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,)) + cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)", + (str(uuid.uuid4()), pid, final_content)) + + inc_ai_usage(pid) + return {"scope": "gesamt", "content": final_content, "stage1": stage1_results} + +@app.get("/api/prompts") +def list_prompts(session: dict=Depends(require_auth)): + """ + List AI prompts. + - Admins: see ALL prompts (including pipeline and inactive) + - Users: see only active single-analysis prompts + """ + with get_db() as conn: + cur = get_cursor(conn) + is_admin = session.get('role') == 'admin' + + if is_admin: + # Admin sees everything + cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug") + else: + # Users see only active, non-pipeline prompts + cur.execute("SELECT * FROM ai_prompts WHERE active=true AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order") + + return [r2d(r) for r in cur.fetchall()] + +@app.put("/api/prompts/{prompt_id}") +def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)): + """Update AI prompt template (admin only).""" + with get_db() as conn: + cur = get_cursor(conn) + updates = [] + values = [] + if 'name' in data: + updates.append('name=%s') + values.append(data['name']) + if 'description' in data: + updates.append('description=%s') + values.append(data['description']) + if 'template' in data: + updates.append('template=%s') + values.append(data['template']) + if 'active' in data: + updates.append('active=%s') + # Convert to boolean (accepts true/false, 1/0) + values.append(bool(data['active'])) + + if updates: + cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s", + values + [prompt_id]) + + return {"ok": True} + +@app.get("/api/ai/usage") +def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Get AI usage stats for current profile.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT ai_limit_day FROM profiles WHERE id=%s", (pid,)) + prof = cur.fetchone() + limit = prof['ai_limit_day'] if prof else None + + today = datetime.now().date().isoformat() + cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today)) + usage = cur.fetchone() + used = usage['call_count'] if usage else 0 + + cur.execute("SELECT date, call_count FROM ai_usage WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) + history = [r2d(r) for r in cur.fetchall()] return { - "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 = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),)) + prof = cur.fetchone() + if not prof: + raise HTTPException(401, "Ungültige Zugangsdaten") # Verify password - if not 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 = get_cursor(conn) + cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,)) return {"ok": True} @app.get("/api/auth/me") -def get_me(session=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.get("/api/auth/status") +def auth_status(): + """Health check endpoint.""" + return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"} + +@app.post("/api/auth/pin") +def change_pin(req: dict, session: dict=Depends(require_auth)): + """Change PIN/password for current user.""" + pid = session['profile_id'] + new_pin = req.get('pin', '') + if len(new_pin) < 4: + raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") + + new_hash = hash_pin(new_pin) with get_db() as conn: - conn.execute("UPDATE profiles SET pin_hash=? WHERE id=?", - (hash_pin(new_pin), session['profile_id'])); conn.commit() + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) + return {"ok": True} -# ── Admin: Profile permissions ──────────────────────────────────────────────── -@app.put("/api/admin/profiles/{pid}/permissions") -def set_permissions(pid: str, p: ProfilePermissions, session=Depends(require_admin)): - 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} - -@app.get("/api/admin/profiles") -def admin_list_profiles(session=Depends(require_admin)): - 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 - -@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") - 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() - 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 - 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 - except Exception as e: - print(f"[EMAIL] Error sending to {to}: {e}") - return False - -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") - +async def password_reset_request(req: PasswordResetRequest, request: Request): + """Request password reset email.""" + email = req.email.lower().strip() with get_db() as conn: - profile = conn.execute( - "SELECT * FROM profiles WHERE LOWER(email)=?", (email,) - ).fetchone() - if not profile: + cur = get_cursor(conn) + cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,)) + prof = cur.fetchone() + if not prof: # Don't reveal if email exists - return {"ok": True, "message": "Falls ein Konto existiert, wurde eine E-Mail gesendet."} - profile = r2d(profile) + return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} - # 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() + # Generate reset token + token = secrets.token_urlsafe(32) + expires = datetime.now() + timedelta(hours=1) - 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") + # 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())) - 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} + # Send email + try: + import smtplib + from email.mime.text import MIMEText + smtp_host = os.getenv("SMTP_HOST") + smtp_port = int(os.getenv("SMTP_PORT", 587)) + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + smtp_from = os.getenv("SMTP_FROM") + app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") + + if smtp_host and smtp_user and smtp_pass: + msg = MIMEText(f"""Hallo {prof['name']}, + +Du hast einen Passwort-Reset angefordert. + +Reset-Link: {app_url}/reset-password?token={token} + +Der Link ist 1 Stunde gültig. + +Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail. + +Dein Mitai Jinkendo Team +""") + msg['Subject'] = "Passwort zurücksetzen – Mitai Jinkendo" + msg['From'] = smtp_from + msg['To'] = email + + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg) + except Exception as e: + print(f"Email error: {e}") + + return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} @app.post("/api/auth/reset-password") -@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") - +def password_reset_confirm(req: PasswordResetConfirm): + """Confirm password reset with token.""" 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 = get_cursor(conn) + cur.execute("SELECT profile_id FROM sessions WHERE token=%s AND expires_at > CURRENT_TIMESTAMP", + (f"reset_{req.token}",)) + sess = cur.fetchone() + if not sess: + raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link") - 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() + 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: dict=Depends(require_admin)): + """Admin: List all profiles with stats.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles ORDER BY created") + profs = [r2d(r) for r in cur.fetchall()] + + for p in profs: + pid = p['id'] + cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s", (pid,)) + p['weight_count'] = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM ai_insights WHERE profile_id=%s", (pid,)) + p['ai_insights_count'] = cur.fetchone()['count'] + + today = datetime.now().date().isoformat() + cur.execute("SELECT call_count FROM ai_usage WHERE profile_id=%s AND date=%s", (pid, today)) + usage = cur.fetchone() + p['ai_usage_today'] = usage['call_count'] if usage else 0 + + return profs + +@app.put("/api/admin/profiles/{pid}") +def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depends(require_admin)): + """Admin: Update profile settings.""" + with get_db() as conn: + updates = {k:v for k,v in data.model_dump().items() if v is not None} + if not updates: + return {"ok": True} + + cur = get_cursor(conn) + cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in updates)} WHERE id=%s", + list(updates.values()) + [pid]) + + return {"ok": True} + +@app.put("/api/admin/profiles/{pid}/permissions") +def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile permissions.""" + with get_db() as conn: + cur = get_cursor(conn) + updates = [] + values = [] + if 'ai_enabled' in data: + updates.append('ai_enabled=%s') + values.append(data['ai_enabled']) + if 'ai_limit_day' in data: + updates.append('ai_limit_day=%s') + values.append(data['ai_limit_day']) + if 'export_enabled' in data: + updates.append('export_enabled=%s') + values.append(data['export_enabled']) + if 'role' in data: + updates.append('role=%s') + values.append(data['role']) + + if updates: + cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid]) + + return {"ok": True} + +@app.put("/api/admin/profiles/{pid}/email") +def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile email.""" + email = data.get('email', '').strip().lower() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid)) + + return {"ok": True} + +@app.put("/api/admin/profiles/{pid}/pin") +def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile PIN/password.""" + new_pin = data.get('pin', '') + if len(new_pin) < 4: + raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") + + new_hash = hash_pin(new_pin) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) return {"ok": True} -# ── E-Mail Settings ─────────────────────────────────────────────────────────── @app.get("/api/admin/email/status") -def email_status(session=Depends(require_admin)): +def admin_email_status(session: dict=Depends(require_admin)): + """Admin: Check email configuration status.""" + smtp_host = os.getenv("SMTP_HOST") + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + app_url = os.getenv("APP_URL", "http://localhost:3002") + + configured = bool(smtp_host and smtp_user and smtp_pass) + return { - "configured": 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, + "configured": configured, + "smtp_host": smtp_host or "", + "smtp_user": smtp_user or "", + "app_url": app_url } @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} +def admin_test_email(data: dict, session: dict=Depends(require_admin)): + """Admin: Send test email.""" + email = data.get('to', '') + if not email: + raise HTTPException(400, "E-Mail-Adresse fehlt") -@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).""" + try: + import smtplib + from email.mime.text import MIMEText + smtp_host = os.getenv("SMTP_HOST") + smtp_port = int(os.getenv("SMTP_PORT", 587)) + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + smtp_from = os.getenv("SMTP_FROM") + + if not smtp_host or not smtp_user or not smtp_pass: + raise HTTPException(500, "SMTP nicht konfiguriert") + + msg = MIMEText("Dies ist eine Test-E-Mail von Mitai Jinkendo.") + msg['Subject'] = "Test-E-Mail" + msg['From'] = smtp_from + msg['To'] = email + + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg) + + return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"} + except Exception as e: + raise HTTPException(500, f"Fehler beim Senden: {str(e)}") + +# ── Export ──────────────────────────────────────────────────────────────────── +@app.get("/api/export/csv") +def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Export all data as CSV.""" + pid = get_pid(x_profile_id) + + # Check export permission with get_db() as conn: - 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") + cur = get_cursor(conn) + cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) + prof = cur.fetchone() + if not prof or not prof['export_enabled']: + raise HTTPException(403, "Export ist für dieses Profil deaktiviert") - # 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] + # Build CSV + output = io.StringIO() + writer = csv.writer(output) - 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 = "" + # Header + writer.writerow(["Typ", "Datum", "Wert", "Details"]) - 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") + # Weight + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT date, weight, note FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) + for r in cur.fetchall(): + writer.writerow(["Gewicht", r['date'], f"{float(r['weight'])}kg", r['note'] or ""]) - 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} + # Circumferences + cur.execute("SELECT date, c_waist, c_belly, c_hip FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) + for r in cur.fetchall(): + details = f"Taille:{float(r['c_waist'])}cm Bauch:{float(r['c_belly'])}cm Hüfte:{float(r['c_hip'])}cm" + writer.writerow(["Umfänge", r['date'], "", details]) + + # Caliper + cur.execute("SELECT date, body_fat_pct, lean_mass FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) + for r in cur.fetchall(): + writer.writerow(["Caliper", r['date'], f"{float(r['body_fat_pct'])}%", f"Magermasse:{float(r['lean_mass'])}kg"]) + + # Nutrition + cur.execute("SELECT date, kcal, protein_g FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) + for r in cur.fetchall(): + writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"]) + + # Activity + cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) + for r in cur.fetchall(): + writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) + + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"} + ) + +@app.get("/api/export/json") +def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Export all data as JSON.""" + pid = get_pid(x_profile_id) + + # Check export permission + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) + prof = cur.fetchone() + if not prof or not prof['export_enabled']: + raise HTTPException(403, "Export ist für dieses Profil deaktiviert") + + # Collect all data + data = {} + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + data['profile'] = r2d(cur.fetchone()) + + cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['weight'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['circumferences'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['caliper'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['nutrition'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['activity'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) + data['insights'] = [r2d(r) for r in cur.fetchall()] + + def decimal_handler(obj): + if isinstance(obj, Decimal): + return float(obj) + return str(obj) + + json_str = json.dumps(data, indent=2, default=decimal_handler) + return Response( + content=json_str, + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.json"} + ) + +@app.get("/api/export/zip") +def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Export all data as ZIP (CSV + JSON + photos) per specification.""" + pid = get_pid(x_profile_id) + + # Check export permission & get profile + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + prof = r2d(cur.fetchone()) + if not prof or not prof.get('export_enabled'): + raise HTTPException(403, "Export ist für dieses Profil deaktiviert") + + # Helper: CSV writer with UTF-8 BOM + semicolon + def write_csv(zf, filename, rows, columns): + if not rows: + return + output = io.StringIO() + writer = csv.writer(output, delimiter=';') + writer.writerow(columns) + for r in rows: + writer.writerow([ + '' if r.get(col) is None else + (float(r[col]) if isinstance(r.get(col), Decimal) else r[col]) + for col in columns + ]) + # UTF-8 with BOM for Excel + csv_bytes = '\ufeff'.encode('utf-8') + output.getvalue().encode('utf-8') + zf.writestr(f"data/{filename}", csv_bytes) + + # Create ZIP + zip_buffer = io.BytesIO() + export_date = datetime.now().strftime('%Y-%m-%d') + profile_name = prof.get('name', 'export') + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + with get_db() as conn: + cur = get_cursor(conn) + + # 1. README.txt + readme = f"""Mitai Jinkendo – Datenexport +Version: 2 +Exportiert am: {export_date} +Profil: {profile_name} + +Inhalt: +- profile.json: Profildaten und Einstellungen +- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8) +- insights/: KI-Auswertungen (JSON) +- photos/: Progress-Fotos (JPEG) + +Import: +Dieser Export kann in Mitai Jinkendo unter +Einstellungen → Import → "Mitai Backup importieren" +wieder eingespielt werden. + +Format-Version 2 (ab v9b): +Alle CSV-Dateien sind UTF-8 mit BOM kodiert. +Trennzeichen: Semikolon (;) +Datumsformat: YYYY-MM-DD +""" + zf.writestr("README.txt", readme.encode('utf-8')) + + # 2. profile.json (ohne Passwort-Hash) + cur.execute("SELECT COUNT(*) as c FROM weight_log WHERE profile_id=%s", (pid,)) + w_count = cur.fetchone()['c'] + cur.execute("SELECT COUNT(*) as c FROM nutrition_log WHERE profile_id=%s", (pid,)) + n_count = cur.fetchone()['c'] + cur.execute("SELECT COUNT(*) as c FROM activity_log WHERE profile_id=%s", (pid,)) + a_count = cur.fetchone()['c'] + cur.execute("SELECT COUNT(*) as c FROM photos WHERE profile_id=%s", (pid,)) + p_count = cur.fetchone()['c'] + + profile_data = { + "export_version": "2", + "export_date": export_date, + "app": "Mitai Jinkendo", + "profile": { + "name": prof.get('name'), + "email": prof.get('email'), + "sex": prof.get('sex'), + "height": float(prof['height']) if prof.get('height') else None, + "birth_year": prof['dob'].year if prof.get('dob') else None, + "goal_weight": float(prof['goal_weight']) if prof.get('goal_weight') else None, + "goal_bf_pct": float(prof['goal_bf_pct']) if prof.get('goal_bf_pct') else None, + "avatar_color": prof.get('avatar_color'), + "auth_type": prof.get('auth_type'), + "session_days": prof.get('session_days'), + "ai_enabled": prof.get('ai_enabled'), + "tier": prof.get('tier') + }, + "stats": { + "weight_entries": w_count, + "nutrition_entries": n_count, + "activity_entries": a_count, + "photos": p_count + } + } + zf.writestr("profile.json", json.dumps(profile_data, indent=2, ensure_ascii=False).encode('utf-8')) + + # 3. data/weight.csv + cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) + write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], + ['id','date','weight','note','source','created']) + + # 4. data/circumferences.csv + cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) + rows = [r2d(r) for r in cur.fetchall()] + # Rename columns to match spec + for r in rows: + r['waist'] = r.pop('c_waist', None) + r['hip'] = r.pop('c_hip', None) + r['chest'] = r.pop('c_chest', None) + r['neck'] = r.pop('c_neck', None) + r['upper_arm'] = r.pop('c_arm', None) + r['thigh'] = r.pop('c_thigh', None) + r['calf'] = r.pop('c_calf', None) + r['forearm'] = None # not tracked + r['note'] = r.pop('notes', None) + write_csv(zf, "circumferences.csv", rows, + ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created']) + + # 5. data/caliper.csv + cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) + rows = [r2d(r) for r in cur.fetchall()] + for r in rows: + r['chest'] = r.pop('sf_chest', None) + r['abdomen'] = r.pop('sf_abdomen', None) + r['thigh'] = r.pop('sf_thigh', None) + r['tricep'] = r.pop('sf_triceps', None) + r['subscapular'] = r.pop('sf_subscap', None) + r['suprailiac'] = r.pop('sf_suprailiac', None) + r['midaxillary'] = r.pop('sf_axilla', None) + r['method'] = r.pop('sf_method', None) + r['bf_percent'] = r.pop('body_fat_pct', None) + r['note'] = r.pop('notes', None) + write_csv(zf, "caliper.csv", rows, + ['id','date','chest','abdomen','thigh','tricep','subscapular','suprailiac','midaxillary','method','bf_percent','note','created']) + + # 6. data/nutrition.csv + cur.execute("SELECT id, date, kcal, protein_g, fat_g, carbs_g, source, created FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) + rows = [r2d(r) for r in cur.fetchall()] + for r in rows: + r['meal_name'] = '' # not tracked per meal + r['protein'] = r.pop('protein_g', None) + r['fat'] = r.pop('fat_g', None) + r['carbs'] = r.pop('carbs_g', None) + r['fiber'] = None # not tracked + r['note'] = '' + write_csv(zf, "nutrition.csv", rows, + ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created']) + + # 7. data/activity.csv + cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) + rows = [r2d(r) for r in cur.fetchall()] + for r in rows: + r['name'] = r['activity_type'] + r['type'] = r.pop('activity_type', None) + r['kcal'] = r.pop('kcal_active', None) + r['heart_rate_avg'] = r.pop('hr_avg', None) + r['heart_rate_max'] = r.pop('hr_max', None) + r['note'] = r.pop('notes', None) + write_csv(zf, "activity.csv", rows, + ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created']) + + # 8. insights/ai_insights.json + cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) + insights = [] + for r in cur.fetchall(): + rd = r2d(r) + insights.append({ + "id": rd['id'], + "scope": rd['scope'], + "created": rd['created'].isoformat() if hasattr(rd['created'], 'isoformat') else str(rd['created']), + "result": rd['content'] + }) + if insights: + zf.writestr("insights/ai_insights.json", json.dumps(insights, indent=2, ensure_ascii=False).encode('utf-8')) + + # 9. photos/ + cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,)) + photos = [r2d(r) for r in cur.fetchall()] + for i, photo in enumerate(photos): + photo_path = Path(PHOTOS_DIR) / photo['path'] + if photo_path.exists(): + filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}" + zf.write(photo_path, f"photos/{filename}") + + zip_buffer.seek(0) + filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip" + return StreamingResponse( + iter([zip_buffer.getvalue()]), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +# ── Import ZIP ────────────────────────────────────────────────── +@app.post("/api/import/zip") +async def import_zip( + file: UploadFile = File(...), + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth) +): + """ + Import data from ZIP export file. + + - Validates export format + - Imports missing entries only (ON CONFLICT DO NOTHING) + - Imports photos + - Returns import summary + - Full rollback on error + """ + pid = get_pid(x_profile_id) + + # Read uploaded file + content = await file.read() + zip_buffer = io.BytesIO(content) + + try: + with zipfile.ZipFile(zip_buffer, 'r') as zf: + # 1. Validate profile.json + if 'profile.json' not in zf.namelist(): + raise HTTPException(400, "Ungültiger Export: profile.json fehlt") + + profile_data = json.loads(zf.read('profile.json').decode('utf-8')) + export_version = profile_data.get('export_version', '1') + + # Stats tracker + stats = { + 'weight': 0, + 'circumferences': 0, + 'caliper': 0, + 'nutrition': 0, + 'activity': 0, + 'photos': 0, + 'insights': 0 + } + + with get_db() as conn: + cur = get_cursor(conn) + + try: + # 2. Import weight.csv + if 'data/weight.csv' in zf.namelist(): + csv_data = zf.read('data/weight.csv').decode('utf-8-sig') + reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') + for row in reader: + cur.execute(""" + INSERT INTO weight_log (profile_id, date, weight, note, source, created) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT (profile_id, date) DO NOTHING + """, ( + pid, + row['date'], + float(row['weight']) if row['weight'] else None, + row.get('note', ''), + row.get('source', 'import'), + row.get('created', datetime.now()) + )) + if cur.rowcount > 0: + stats['weight'] += 1 + + # 3. Import circumferences.csv + if 'data/circumferences.csv' in zf.namelist(): + csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig') + reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') + for row in reader: + # Map CSV columns to DB columns + cur.execute(""" + INSERT INTO circumference_log ( + profile_id, date, c_waist, c_hip, c_chest, c_neck, + c_arm, c_thigh, c_calf, notes, created + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (profile_id, date) DO NOTHING + """, ( + pid, + row['date'], + float(row['waist']) if row.get('waist') else None, + float(row['hip']) if row.get('hip') else None, + float(row['chest']) if row.get('chest') else None, + float(row['neck']) if row.get('neck') else None, + float(row['upper_arm']) if row.get('upper_arm') else None, + float(row['thigh']) if row.get('thigh') else None, + float(row['calf']) if row.get('calf') else None, + row.get('note', ''), + row.get('created', datetime.now()) + )) + if cur.rowcount > 0: + stats['circumferences'] += 1 + + # 4. Import caliper.csv + if 'data/caliper.csv' in zf.namelist(): + csv_data = zf.read('data/caliper.csv').decode('utf-8-sig') + reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') + for row in reader: + cur.execute(""" + INSERT INTO caliper_log ( + profile_id, date, sf_chest, sf_abdomen, sf_thigh, + sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, + sf_method, body_fat_pct, notes, created + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (profile_id, date) DO NOTHING + """, ( + pid, + row['date'], + float(row['chest']) if row.get('chest') else None, + float(row['abdomen']) if row.get('abdomen') else None, + float(row['thigh']) if row.get('thigh') else None, + float(row['tricep']) if row.get('tricep') else None, + float(row['subscapular']) if row.get('subscapular') else None, + float(row['suprailiac']) if row.get('suprailiac') else None, + float(row['midaxillary']) if row.get('midaxillary') else None, + row.get('method', 'jackson3'), + float(row['bf_percent']) if row.get('bf_percent') else None, + row.get('note', ''), + row.get('created', datetime.now()) + )) + if cur.rowcount > 0: + stats['caliper'] += 1 + + # 5. Import nutrition.csv + if 'data/nutrition.csv' in zf.namelist(): + csv_data = zf.read('data/nutrition.csv').decode('utf-8-sig') + reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') + for row in reader: + cur.execute(""" + INSERT INTO nutrition_log ( + profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (profile_id, date) DO NOTHING + """, ( + pid, + row['date'], + float(row['kcal']) if row.get('kcal') else None, + float(row['protein']) if row.get('protein') else None, + float(row['fat']) if row.get('fat') else None, + float(row['carbs']) if row.get('carbs') else None, + row.get('source', 'import'), + row.get('created', datetime.now()) + )) + if cur.rowcount > 0: + stats['nutrition'] += 1 + + # 6. Import activity.csv + if 'data/activity.csv' in zf.namelist(): + csv_data = zf.read('data/activity.csv').decode('utf-8-sig') + reader = csv.DictReader(io.StringIO(csv_data), delimiter=';') + for row in reader: + cur.execute(""" + INSERT INTO activity_log ( + profile_id, date, activity_type, duration_min, + kcal_active, hr_avg, hr_max, distance_km, notes, source, created + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + pid, + row['date'], + row.get('type', 'Training'), + float(row['duration_min']) if row.get('duration_min') else None, + float(row['kcal']) if row.get('kcal') else None, + float(row['heart_rate_avg']) if row.get('heart_rate_avg') else None, + float(row['heart_rate_max']) if row.get('heart_rate_max') else None, + float(row['distance_km']) if row.get('distance_km') else None, + row.get('note', ''), + row.get('source', 'import'), + row.get('created', datetime.now()) + )) + if cur.rowcount > 0: + stats['activity'] += 1 + + # 7. Import ai_insights.json + if 'insights/ai_insights.json' in zf.namelist(): + insights_data = json.loads(zf.read('insights/ai_insights.json').decode('utf-8')) + for insight in insights_data: + cur.execute(""" + INSERT INTO ai_insights (profile_id, scope, content, created) + VALUES (%s, %s, %s, %s) + """, ( + pid, + insight['scope'], + insight['result'], + insight.get('created', datetime.now()) + )) + stats['insights'] += 1 + + # 8. Import photos + photo_files = [f for f in zf.namelist() if f.startswith('photos/') and not f.endswith('/')] + for photo_file in photo_files: + # Extract date from filename (format: YYYY-MM-DD_N.jpg) + filename = Path(photo_file).name + parts = filename.split('_') + photo_date = parts[0] if len(parts) > 0 else datetime.now().strftime('%Y-%m-%d') + + # Generate new ID and path + photo_id = str(uuid.uuid4()) + ext = Path(filename).suffix + new_filename = f"{photo_id}{ext}" + target_path = PHOTOS_DIR / new_filename + + # Check if photo already exists for this date + cur.execute(""" + SELECT id FROM photos + WHERE profile_id = %s AND date = %s + """, (pid, photo_date)) + + if cur.fetchone() is None: + # Write photo file + with open(target_path, 'wb') as f: + f.write(zf.read(photo_file)) + + # Insert DB record + cur.execute(""" + INSERT INTO photos (id, profile_id, date, path, created) + VALUES (%s, %s, %s, %s, %s) + """, (photo_id, pid, photo_date, new_filename, datetime.now())) + stats['photos'] += 1 + + # Commit transaction + conn.commit() + + except Exception as e: + # Rollback on any error + conn.rollback() + raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}") + + return { + "ok": True, + "message": "Import erfolgreich", + "stats": stats, + "total": sum(stats.values()) + } + + except zipfile.BadZipFile: + raise HTTPException(400, "Ungültige ZIP-Datei") + except Exception as e: + raise HTTPException(500, f"Import-Fehler: {str(e)}") diff --git a/backend/migrate_to_postgres.py b/backend/migrate_to_postgres.py new file mode 100644 index 0000000..41a9371 --- /dev/null +++ b/backend/migrate_to_postgres.py @@ -0,0 +1,373 @@ +#!/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 + + # Empty string → NULL for DATE columns (PostgreSQL doesn't accept '' for DATE type) + if isinstance(value, str) and value.strip() == '' and column == 'date': + 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..a921b78 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,263 @@ +-- ================================================================ +-- 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, + meas_id UUID, -- Legacy: reference to measurement (circumference/caliper) + 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; + +DROP TRIGGER IF EXISTS trigger_profiles_updated ON profiles; +CREATE TRIGGER trigger_profiles_updated + BEFORE UPDATE ON profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_timestamp(); + +DROP TRIGGER IF EXISTS trigger_ai_prompts_updated ON ai_prompts; +CREATE TRIGGER 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..5ee2bb4 --- /dev/null +++ b/backend/startup.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +# Run database initialization with Python (no psql needed!) +python /app/db_init.py + +if [ $? -ne 0 ]; then + echo "✗ Database initialization failed" + exit 1 +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..58da367 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -1,24 +1,56 @@ 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,6 +65,7 @@ services: - backend volumes: + mitai_dev_postgres_data: bodytrack_bodytrack-data: external: true bodytrack_bodytrack-photos: diff --git a/docker-compose.yml b/docker-compose.yml index a588a84..77ef9ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,55 @@ services: + postgres: + image: postgres:16-alpine + container_name: mitai-db + restart: unless-stopped + environment: + POSTGRES_DB: mitai_prod + POSTGRES_USER: mitai_prod + POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me_in_production} + volumes: + - mitai_postgres_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mitai_prod"] + 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=postgres + - DB_PORT=5432 + - DB_NAME=mitai_prod + - DB_USER=mitai_prod + - DB_PASSWORD=${DB_PASSWORD:-change_me_in_production} + + # 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 @@ -33,6 +66,7 @@ services: - backend volumes: + mitai_postgres_data: bodytrack_bodytrack-data: external: true bodytrack_bodytrack-photos: diff --git a/frontend/src/pages/Analysis.jsx b/frontend/src/pages/Analysis.jsx index 3838f06..630bc1b 100644 --- a/frontend/src/pages/Analysis.jsx +++ b/frontend/src/pages/Analysis.jsx @@ -150,8 +150,11 @@ export default function Analysis() { } const savePrompt = async (promptId, data) => { + const token = localStorage.getItem('bodytrack_token')||'' await fetch(`/api/prompts/${promptId}`, { - method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data) + method:'PUT', + headers:{'Content-Type':'application/json', 'X-Auth-Token': token}, + body:JSON.stringify(data) }) setEditing(null); await loadAll() } @@ -176,6 +179,10 @@ export default function Analysis() { const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_')) + // Pipeline is available if the "pipeline" prompt is active + const pipelinePrompt = prompts.find(p=>p.slug==='pipeline') + const pipelineAvailable = pipelinePrompt?.active ?? true // Default to true if not found (backwards compatibility) + return (

KI-Analyse

@@ -218,36 +225,38 @@ export default function Analysis() {
)} - {/* Pipeline button */} -
-
-
-
🔬 Mehrstufige Gesamtanalyse
-
- 3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität), - dann Synthese + Zielabgleich. Detaillierteste Auswertung. -
- {allInsights.find(i=>i.scope==='pipeline') && ( -
- Letzte Analyse: {dayjs(allInsights.find(i=>i.scope==='pipeline').created).format('DD.MM.YYYY, HH:mm')} + {/* Pipeline button - only if all sub-prompts are active */} + {pipelineAvailable && ( +
+
+
+
🔬 Mehrstufige Gesamtanalyse
+
+ 3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität), + dann Synthese + Zielabgleich. Detaillierteste Auswertung.
- )} + {allInsights.find(i=>i.scope==='pipeline') && ( +
+ Letzte Analyse: {dayjs(allInsights.find(i=>i.scope==='pipeline').created).format('DD.MM.YYYY, HH:mm')} +
+ )} +
+ + {!canUseAI &&
🔒 KI nicht freigeschaltet
}
- - {!canUseAI &&
🔒 KI nicht freigeschaltet
} + {pipelineLoading && ( +
+ ⚡ Stufe 1: 3 parallele Analyse-Calls… dann Synthese… dann Zielabgleich +
+ )}
- {pipelineLoading && ( -
- ⚡ Stufe 1: 3 parallele Analyse-Calls… dann Synthese… dann Zielabgleich -
- )} -
+ )} {!canUseAI && (
{singlePrompts.map(p=>( -
+
{SLUG_LABELS[p.slug]||p.name} - {!p.active && Inaktiv} + {!p.active && ⏸ Deaktiviert}
{p.description &&
{p.description}
}
@@ -372,26 +386,60 @@ export default function Analysis() { ))} {/* Pipeline prompts */} -
- Mehrstufige Pipeline -
-
- ⚠️ Hinweis: Pipeline-Stage-1-Prompts müssen valides JSON zurückgeben. - Halte das JSON-Format im Prompt erhalten. Stage 2 + 3 können frei angepasst werden. +
+
+ Mehrstufige Pipeline +
+ {(() => { + const pipelinePrompt = prompts.find(p=>p.slug==='pipeline') + return pipelinePrompt && ( + + ) + })()}
+ {(() => { + const pipelinePrompt = prompts.find(p=>p.slug==='pipeline') + const isPipelineActive = pipelinePrompt?.active ?? true + return ( +
+ {isPipelineActive ? ( + <>⚠️ Hinweis: Pipeline-Stage-1-Prompts müssen valides JSON zurückgeben. + Halte das JSON-Format im Prompt erhalten. Stage 2 + 3 können frei angepasst werden. + ) : ( + <>⏸ Pipeline deaktiviert: Die mehrstufige Gesamtanalyse ist aktuell nicht verfügbar. + Aktiviere sie mit dem Schalter oben, um sie auf der Analyse-Seite zu nutzen. + )} +
+ ) + })()} {pipelinePrompts.map(p=>{ const isJson = jsonSlugs.includes(p.slug) return (
+ style={{borderLeft:`3px solid ${isJson?'var(--warn)':'var(--accent)'}`,opacity:p.active?1:0.6}}>
{p.name} {isJson && JSON-Output} + {!p.active && ⏸ Deaktiviert}
{p.description &&
{p.description}
}
diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index b3bfe98..5e92190 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -147,7 +147,7 @@ function PeriodSelector({ value, onChange }) { } // ── Body Section (Weight + Composition combined) ────────────────────────────── -function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug }) { +function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(90) const sex = profile?.sex||'m' const height = profile?.height||178 @@ -394,14 +394,14 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
)} -
) } // ── Nutrition Section ───────────────────────────────────────────────────────── -function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug }) { +function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(30) if (!nutrition?.length) return ( @@ -579,13 +579,13 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
BEWERTUNG
{macroRules.map((item,i)=>)}
- +
) } // ── Activity Section ────────────────────────────────────────────────────────── -function ActivitySection({ activities, insights, onRequest, loadingSlug }) { +function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs }) { const [period, setPeriod] = useState(30) if (!activities?.length) return ( @@ -657,13 +657,13 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug }) {
BEWERTUNG
{actRules.map((item,i)=>)}
- +
) } // ── Correlation Section ─────────────────────────────────────────────────────── -function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug }) { +function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) { const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight) if (filtered.length < 5) return ( @@ -852,7 +852,7 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
)} - +
) } @@ -903,6 +903,7 @@ export default function History() { const [activities, setActivities] = useState([]) const [corrData, setCorrData] = useState([]) const [insights, setInsights] = useState([]) + const [prompts, setPrompts] = useState([]) const [profile, setProfile] = useState(null) const [loading, setLoading] = useState(true) const [loadingSlug,setLoadingSlug]= useState(null) @@ -911,10 +912,12 @@ export default function History() { api.listWeight(365), api.listCaliper(), api.listCirc(), api.listNutrition(90), api.listActivity(200), api.nutritionCorrelations(), api.latestInsights(), api.getProfile(), - ]).then(([w,ca,ci,n,a,corr,ins,p])=>{ + api.listPrompts(), + ]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{ setWeights(w); setCalipers(ca); setCircs(ci) setNutrition(n); setActivities(a); setCorrData(corr) setInsights(Array.isArray(ins)?ins:[]); setProfile(p) + setPrompts(Array.isArray(pr)?pr:[]) setLoading(false) }) @@ -923,17 +926,23 @@ export default function History() { const requestInsight = async (slug) => { setLoadingSlug(slug) try { - const pid=localStorage.getItem('bodytrack_active_profile')||'' - const r=await api.runInsight(slug) - if(!r.ok) throw new Error(await r.text()) - const ins=await api.latestInsights() + const result = await api.runInsight(slug) + // result is already JSON, not a Response object + const ins = await api.latestInsights() setInsights(Array.isArray(ins)?ins:[]) - } catch(e){ alert('KI-Fehler: '+e.message) } + } catch(e){ + alert('KI-Fehler: '+e.message) + } finally{ setLoadingSlug(null) } } if(loading) return
- const sp={insights,onRequest:requestInsight,loadingSlug} + + // Filter active prompts + const activeSlugs = prompts.filter(p=>p.active).map(p=>p.slug) + const filterActiveSlugs = (slugs) => slugs.filter(s=>activeSlugs.includes(s)) + + const sp={insights,onRequest:requestInsight,loadingSlug,filterActiveSlugs} return (
diff --git a/frontend/src/pages/LoginScreen.jsx b/frontend/src/pages/LoginScreen.jsx index 7035c41..2e3779a 100644 --- a/frontend/src/pages/LoginScreen.jsx +++ b/frontend/src/pages/LoginScreen.jsx @@ -32,7 +32,7 @@ export default function LoginScreen() { } setLoading(true); setError(null) try { - await login({ email: email.trim().toLowerCase(), pin: password }) + await login({ email: email.trim().toLowerCase(), password: password }) } catch(e) { setError(e.message || 'Ungültige E-Mail oder Passwort') } finally { setLoading(false) } diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 27e5b23..b5a0de0 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Save, Download, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react' +import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react' import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' import { Avatar } from './ProfileSelect' @@ -123,6 +123,73 @@ export default function SettingsPage() { // editingId: string ID of profile being edited, or 'new' for new profile, or null const [editingId, setEditingId] = useState(null) const [saved, setSaved] = useState(false) + const [importing, setImporting] = useState(false) + const [importMsg, setImportMsg] = useState(null) + + const handleImport = async (e) => { + const file = e.target.files?.[0] + if (!file) return + + if (!confirm(`Backup "${file.name}" importieren? Vorhandene Einträge werden nicht überschrieben.`)) { + e.target.value = '' // Reset file input + return + } + + setImporting(true) + setImportMsg(null) + + try { + const formData = new FormData() + formData.append('file', file) + + const token = localStorage.getItem('bodytrack_token')||'' + const pid = localStorage.getItem('bodytrack_active_profile')||'' + + const res = await fetch('/api/import/zip', { + method: 'POST', + headers: { + 'X-Auth-Token': token, + 'X-Profile-Id': pid + }, + body: formData + }) + + const data = await res.json() + + if (!res.ok) { + throw new Error(data.detail || 'Import fehlgeschlagen') + } + + // Show success message with stats + const stats = data.stats + const lines = [] + if (stats.weight > 0) lines.push(`${stats.weight} Gewicht`) + if (stats.circumferences > 0) lines.push(`${stats.circumferences} Umfänge`) + if (stats.caliper > 0) lines.push(`${stats.caliper} Caliper`) + if (stats.nutrition > 0) lines.push(`${stats.nutrition} Ernährung`) + if (stats.activity > 0) lines.push(`${stats.activity} Aktivität`) + if (stats.photos > 0) lines.push(`${stats.photos} Fotos`) + if (stats.insights > 0) lines.push(`${stats.insights} KI-Analysen`) + + setImportMsg({ + type: 'success', + text: `✓ Import erfolgreich: ${lines.join(', ')}` + }) + + // Refresh data (in case new entries were added) + await refreshProfiles() + + } catch (err) { + setImportMsg({ + type: 'error', + text: `✗ ${err.message}` + }) + } finally { + setImporting(false) + e.target.value = '' // Reset file input + setTimeout(() => setImportMsg(null), 5000) + } + } const handleSave = async (form, profileId) => { const data = {} @@ -291,12 +358,12 @@ export default function SettingsPage() { )} {canExport && <> @@ -307,6 +374,55 @@ export default function SettingsPage() {

+ {/* Import */} +
+
Backup importieren
+

+ Importiere einen ZIP-Export zurück in {activeProfile?.name}. + Vorhandene Einträge werden nicht überschrieben. +

+
+ {!canExport && ( +
+ 🔒 Import ist für dein Profil nicht freigeschaltet. Bitte den Admin kontaktieren. +
+ )} + {canExport && ( + <> + + {importMsg && ( +
+ {importMsg.text} +
+ )} + + )} +
+

+ Der Import erkennt automatisch das Format und importiert nur neue Einträge. +

+
+ {saved && (
r.json()) }, listPhotos: () => req('/photos'), - photoUrl: (pid) => `${BASE}/photos/${pid}`, + photoUrl: (pid) => { + const token = getToken() + return `${BASE}/photos/${pid}${token ? `?token=${token}` : ''}` + }, // Nutrition importCsv: async(file)=>{ @@ -88,9 +91,45 @@ export const api = { insightPipeline: () => req('/insights/pipeline',{method:'POST'}), listInsights: () => req('/insights'), latestInsights: () => req('/insights/latest'), - exportZip: () => window.open(`${BASE}/export/zip`), - exportJson: () => window.open(`${BASE}/export/json`), - exportCsv: () => window.open(`${BASE}/export/csv`), + exportZip: async () => { + const res = await fetch(`${BASE}/export/zip`, {headers: hdrs()}) + if (!res.ok) throw new Error('Export failed') + const blob = await res.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `mitai-export-${new Date().toISOString().split('T')[0]}.zip` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + }, + exportJson: async () => { + const res = await fetch(`${BASE}/export/json`, {headers: hdrs()}) + if (!res.ok) throw new Error('Export failed') + const blob = await res.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `mitai-export-${new Date().toISOString().split('T')[0]}.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + }, + exportCsv: async () => { + const res = await fetch(`${BASE}/export/csv`, {headers: hdrs()}) + if (!res.ok) throw new Error('Export failed') + const blob = await res.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `mitai-export-${new Date().toISOString().split('T')[0]}.csv` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + }, // Admin adminListProfiles: () => req('/admin/profiles'),