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

Hallo {profile['name']},

-

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

- Passwort zurücksetzen -

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

-
-

Oder kopiere diesen Link:
- {reset_url}

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

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

-

✓ E-Mail-Versand funktioniert korrekt!

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

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

- - - - - - - -
⚖️ Gewicht{w_text} {delta_html}
🍽️ Ernährung{n_text}
🏋️ Trainings{acts}× diese Woche
- App öffnen - """, "Deine Wochenzusammenfassung") - - sent = send_email(profile['email'], f"Mitai Jinkendo – Woche vom {datetime.now().strftime('%d.%m.%Y')}", html) - if not sent: raise HTTPException(500, "Senden fehlgeschlagen") - return {"ok": True} + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"} + ) diff --git a/backend/migrate_to_postgres.py b/backend/migrate_to_postgres.py new file mode 100644 index 0000000..b6a6dab --- /dev/null +++ b/backend/migrate_to_postgres.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +SQLite → PostgreSQL Migration Script für Mitai Jinkendo (v9a → v9b) + +Migrates all data from SQLite to PostgreSQL with type conversions and validation. + +Usage: + # Inside Docker container: + python migrate_to_postgres.py + + # Or locally with custom paths: + DATA_DIR=./data DB_HOST=localhost python migrate_to_postgres.py + +Environment Variables: + SQLite Source: + DATA_DIR (default: ./data) + + PostgreSQL Target: + DB_HOST (default: postgres) + DB_PORT (default: 5432) + DB_NAME (default: mitai) + DB_USER (default: mitai) + DB_PASSWORD (required) +""" +import os +import sys +import sqlite3 +from pathlib import Path +from typing import Dict, Any, List, Optional +import psycopg2 +from psycopg2.extras import execute_values, RealDictCursor + + +# ================================================================ +# CONFIGURATION +# ================================================================ + +# SQLite Source +DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) +SQLITE_DB = DATA_DIR / "bodytrack.db" + +# PostgreSQL Target +PG_CONFIG = { + 'host': os.getenv("DB_HOST", "postgres"), + 'port': int(os.getenv("DB_PORT", "5432")), + 'database': os.getenv("DB_NAME", "mitai"), + 'user': os.getenv("DB_USER", "mitai"), + 'password': os.getenv("DB_PASSWORD", "") +} + +# Tables to migrate (in order - respects foreign keys) +TABLES = [ + 'profiles', + 'sessions', + 'ai_usage', + 'ai_prompts', + 'weight_log', + 'circumference_log', + 'caliper_log', + 'nutrition_log', + 'activity_log', + 'photos', + 'ai_insights', +] + +# Columns that need INTEGER (0/1) → BOOLEAN conversion +BOOLEAN_COLUMNS = { + 'profiles': ['ai_enabled', 'export_enabled'], + 'ai_prompts': ['active'], +} + + +# ================================================================ +# CONVERSION HELPERS +# ================================================================ + +def convert_value(value: Any, column: str, table: str) -> Any: + """ + Convert SQLite value to PostgreSQL-compatible format. + + Args: + value: Raw value from SQLite + column: Column name + table: Table name + + Returns: + Converted value suitable for PostgreSQL + """ + # NULL values pass through + if value is None: + return None + + # INTEGER → BOOLEAN conversion + if table in BOOLEAN_COLUMNS and column in BOOLEAN_COLUMNS[table]: + return bool(value) + + # All other values pass through + # (PostgreSQL handles TEXT timestamps, UUIDs, and numerics automatically) + return value + + +def convert_row(row: Dict[str, Any], table: str) -> Dict[str, Any]: + """ + Convert entire row from SQLite to PostgreSQL format. + + Args: + row: Dictionary with column:value pairs from SQLite + table: Table name + + Returns: + Converted dictionary + """ + return { + column: convert_value(value, column, table) + for column, value in row.items() + } + + +# ================================================================ +# MIGRATION LOGIC +# ================================================================ + +def get_sqlite_rows(table: str) -> List[Dict[str, Any]]: + """ + Fetch all rows from SQLite table. + + Args: + table: Table name + + Returns: + List of dictionaries (one per row) + """ + conn = sqlite3.connect(SQLITE_DB) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + try: + rows = cur.execute(f"SELECT * FROM {table}").fetchall() + return [dict(row) for row in rows] + except sqlite3.OperationalError as e: + # Table doesn't exist in SQLite (OK, might be new in v9b) + print(f" ⚠ Table '{table}' not found in SQLite: {e}") + return [] + finally: + conn.close() + + +def migrate_table(pg_conn, table: str) -> Dict[str, int]: + """ + Migrate one table from SQLite to PostgreSQL. + + Args: + pg_conn: PostgreSQL connection + table: Table name + + Returns: + Dictionary with stats: {'sqlite_count': N, 'postgres_count': M} + """ + print(f" Migrating '{table}'...", end=' ', flush=True) + + # Fetch from SQLite + sqlite_rows = get_sqlite_rows(table) + sqlite_count = len(sqlite_rows) + + if sqlite_count == 0: + print("(empty)") + return {'sqlite_count': 0, 'postgres_count': 0} + + # Convert rows + converted_rows = [convert_row(row, table) for row in sqlite_rows] + + # Get column names + columns = list(converted_rows[0].keys()) + cols_str = ', '.join(columns) + placeholders = ', '.join(['%s'] * len(columns)) + + # Insert into PostgreSQL + pg_cur = pg_conn.cursor() + + # Build INSERT query + query = f"INSERT INTO {table} ({cols_str}) VALUES %s" + + # Prepare values (list of tuples) + values = [ + tuple(row[col] for col in columns) + for row in converted_rows + ] + + # Batch insert with execute_values (faster than executemany) + try: + execute_values(pg_cur, query, values, page_size=100) + except psycopg2.Error as e: + print(f"\n ✗ Insert failed: {e}") + raise + + # Verify row count + pg_cur.execute(f"SELECT COUNT(*) FROM {table}") + postgres_count = pg_cur.fetchone()[0] + + print(f"✓ {sqlite_count} rows → {postgres_count} rows") + + return { + 'sqlite_count': sqlite_count, + 'postgres_count': postgres_count + } + + +def verify_migration(pg_conn, stats: Dict[str, Dict[str, int]]): + """ + Verify migration integrity. + + Args: + pg_conn: PostgreSQL connection + stats: Migration stats per table + """ + print("\n═══════════════════════════════════════════════════════════") + print("VERIFICATION") + print("═══════════════════════════════════════════════════════════") + + all_ok = True + + for table, counts in stats.items(): + sqlite_count = counts['sqlite_count'] + postgres_count = counts['postgres_count'] + + status = "✓" if sqlite_count == postgres_count else "✗" + print(f" {status} {table:20s} SQLite: {sqlite_count:5d} → PostgreSQL: {postgres_count:5d}") + + if sqlite_count != postgres_count: + all_ok = False + + # Sample some data + print("\n───────────────────────────────────────────────────────────") + print("SAMPLE DATA (first profile)") + print("───────────────────────────────────────────────────────────") + + cur = pg_conn.cursor(cursor_factory=RealDictCursor) + cur.execute("SELECT * FROM profiles LIMIT 1") + profile = cur.fetchone() + + if profile: + for key, value in dict(profile).items(): + print(f" {key:20s} = {value}") + else: + print(" (no profiles found)") + + print("\n───────────────────────────────────────────────────────────") + print("SAMPLE DATA (latest weight entry)") + print("───────────────────────────────────────────────────────────") + + cur.execute("SELECT * FROM weight_log ORDER BY date DESC LIMIT 1") + weight = cur.fetchone() + + if weight: + for key, value in dict(weight).items(): + print(f" {key:20s} = {value}") + else: + print(" (no weight entries found)") + + print("\n═══════════════════════════════════════════════════════════") + + if all_ok: + print("✓ MIGRATION SUCCESSFUL - All row counts match!") + else: + print("✗ MIGRATION FAILED - Row count mismatch detected!") + sys.exit(1) + + +# ================================================================ +# MAIN +# ================================================================ + +def main(): + print("═══════════════════════════════════════════════════════════") + print("MITAI JINKENDO - SQLite → PostgreSQL Migration (v9a → v9b)") + print("═══════════════════════════════════════════════════════════\n") + + # Check SQLite DB exists + if not SQLITE_DB.exists(): + print(f"✗ SQLite database not found: {SQLITE_DB}") + print(f" Set DATA_DIR environment variable if needed.") + sys.exit(1) + + print(f"✓ SQLite source: {SQLITE_DB}") + print(f"✓ PostgreSQL target: {PG_CONFIG['user']}@{PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['database']}\n") + + # Check PostgreSQL password + if not PG_CONFIG['password']: + print("✗ DB_PASSWORD environment variable not set!") + sys.exit(1) + + # Connect to PostgreSQL + print("Connecting to PostgreSQL...", end=' ', flush=True) + try: + pg_conn = psycopg2.connect(**PG_CONFIG) + print("✓") + except psycopg2.Error as e: + print(f"\n✗ Connection failed: {e}") + print("\nTroubleshooting:") + print(" - Is PostgreSQL running? (docker compose ps)") + print(" - Is DB_PASSWORD correct?") + print(" - Is the schema initialized? (schema.sql loaded?)") + sys.exit(1) + + # Check if schema is initialized + print("Checking PostgreSQL schema...", end=' ', flush=True) + cur = pg_conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'profiles' + """) + if cur.fetchone()[0] == 0: + print("\n✗ Schema not initialized!") + print("\nRun this first:") + print(" docker compose exec backend python -c \"from main import init_db; init_db()\"") + print(" Or manually load schema.sql") + sys.exit(1) + print("✓") + + # Check if PostgreSQL is empty + print("Checking if PostgreSQL is empty...", end=' ', flush=True) + cur.execute("SELECT COUNT(*) FROM profiles") + existing_profiles = cur.fetchone()[0] + if existing_profiles > 0: + print(f"\n⚠ WARNING: PostgreSQL already has {existing_profiles} profiles!") + response = input(" Continue anyway? This will create duplicates! (yes/no): ") + if response.lower() != 'yes': + print("Migration cancelled.") + sys.exit(0) + else: + print("✓") + + print("\n───────────────────────────────────────────────────────────") + print("MIGRATION") + print("───────────────────────────────────────────────────────────") + + stats = {} + + try: + for table in TABLES: + stats[table] = migrate_table(pg_conn, table) + + # Commit all changes + pg_conn.commit() + print("\n✓ All changes committed to PostgreSQL") + + except Exception as e: + print(f"\n✗ Migration failed: {e}") + print("Rolling back...") + pg_conn.rollback() + pg_conn.close() + sys.exit(1) + + # Verification + verify_migration(pg_conn, stats) + + # Cleanup + pg_conn.close() + + print("\n✓ Migration complete!") + print("\nNext steps:") + print(" 1. Test login with existing credentials") + print(" 2. Check Dashboard (weight chart, stats)") + print(" 3. Verify KI-Analysen work") + print(" 4. If everything works: commit + push to develop") + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index e5781ac..99f7983 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ aiofiles==23.2.1 pydantic==2.7.1 bcrypt==4.1.3 slowapi==0.1.9 +psycopg2-binary==2.9.9 diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..56300f6 --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,260 @@ +-- ================================================================ +-- MITAI JINKENDO v9b – PostgreSQL Schema +-- ================================================================ +-- Migration from SQLite to PostgreSQL +-- Includes v9b Tier System features +-- ================================================================ + +-- Enable UUID Extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ================================================================ +-- CORE TABLES +-- ================================================================ + +-- ── Profiles Table ────────────────────────────────────────────── +-- User/Profile management with auth and permissions +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL DEFAULT 'Nutzer', + avatar_color VARCHAR(7) DEFAULT '#1D9E75', + photo_id UUID, + sex VARCHAR(1) DEFAULT 'm' CHECK (sex IN ('m', 'w', 'd')), + dob DATE, + height NUMERIC(5,2) DEFAULT 178, + goal_weight NUMERIC(5,2), + goal_bf_pct NUMERIC(4,2), + + -- Auth & Permissions + role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('user', 'admin')), + pin_hash TEXT, + auth_type VARCHAR(20) DEFAULT 'pin' CHECK (auth_type IN ('pin', 'email')), + session_days INTEGER DEFAULT 30, + ai_enabled BOOLEAN DEFAULT TRUE, + ai_limit_day INTEGER, + export_enabled BOOLEAN DEFAULT TRUE, + email VARCHAR(255) UNIQUE, + + -- v9b: Tier System + tier VARCHAR(20) DEFAULT 'free' CHECK (tier IN ('free', 'basic', 'premium', 'selfhosted')), + tier_expires_at TIMESTAMP WITH TIME ZONE, + trial_ends_at TIMESTAMP WITH TIME ZONE, + invited_by UUID REFERENCES profiles(id), + + -- Timestamps + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_profiles_email ON profiles(email) WHERE email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_profiles_tier ON profiles(tier); + +-- ── Sessions Table ────────────────────────────────────────────── +-- Auth token management +CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(64) PRIMARY KEY, + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_sessions_profile_id ON sessions(profile_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +-- ── AI Usage Tracking ─────────────────────────────────────────── +-- Daily AI call limits per profile +CREATE TABLE IF NOT EXISTS ai_usage ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + call_count INTEGER DEFAULT 0, + UNIQUE(profile_id, date) +); + +CREATE INDEX IF NOT EXISTS idx_ai_usage_profile_date ON ai_usage(profile_id, date); + +-- ================================================================ +-- TRACKING TABLES +-- ================================================================ + +-- ── Weight Log ────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS weight_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + weight NUMERIC(5,2) NOT NULL, + note TEXT, + source VARCHAR(20) DEFAULT 'manual', + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_weight_log_profile_date ON weight_log(profile_id, date DESC); +CREATE UNIQUE INDEX IF NOT EXISTS idx_weight_log_profile_date_unique ON weight_log(profile_id, date); + +-- ── Circumference Log ─────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS circumference_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + c_neck NUMERIC(5,2), + c_chest NUMERIC(5,2), + c_waist NUMERIC(5,2), + c_belly NUMERIC(5,2), + c_hip NUMERIC(5,2), + c_thigh NUMERIC(5,2), + c_calf NUMERIC(5,2), + c_arm NUMERIC(5,2), + notes TEXT, + photo_id UUID, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_circumference_profile_date ON circumference_log(profile_id, date DESC); + +-- ── Caliper Log ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS caliper_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + sf_method VARCHAR(20) DEFAULT 'jackson3', + sf_chest NUMERIC(5,2), + sf_axilla NUMERIC(5,2), + sf_triceps NUMERIC(5,2), + sf_subscap NUMERIC(5,2), + sf_suprailiac NUMERIC(5,2), + sf_abdomen NUMERIC(5,2), + sf_thigh NUMERIC(5,2), + sf_calf_med NUMERIC(5,2), + sf_lowerback NUMERIC(5,2), + sf_biceps NUMERIC(5,2), + body_fat_pct NUMERIC(4,2), + lean_mass NUMERIC(5,2), + fat_mass NUMERIC(5,2), + notes TEXT, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_caliper_profile_date ON caliper_log(profile_id, date DESC); + +-- ── Nutrition Log ─────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS nutrition_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + kcal NUMERIC(7,2), + protein_g NUMERIC(6,2), + fat_g NUMERIC(6,2), + carbs_g NUMERIC(6,2), + source VARCHAR(20) DEFAULT 'csv', + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_nutrition_profile_date ON nutrition_log(profile_id, date DESC); + +-- ── Activity Log ──────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS activity_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE NOT NULL, + start_time TIME, + end_time TIME, + activity_type VARCHAR(50) NOT NULL, + duration_min NUMERIC(6,2), + kcal_active NUMERIC(7,2), + kcal_resting NUMERIC(7,2), + hr_avg NUMERIC(5,2), + hr_max NUMERIC(5,2), + distance_km NUMERIC(7,2), + rpe INTEGER CHECK (rpe >= 1 AND rpe <= 10), + source VARCHAR(20) DEFAULT 'manual', + notes TEXT, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_activity_profile_date ON activity_log(profile_id, date DESC); + +-- ── Photos ────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS photos ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + date DATE, + path TEXT NOT NULL, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_photos_profile_date ON photos(profile_id, date DESC); + +-- ================================================================ +-- AI TABLES +-- ================================================================ + +-- ── AI Insights ───────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ai_insights ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + scope VARCHAR(50) NOT NULL, + content TEXT NOT NULL, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ai_insights_profile_scope ON ai_insights(profile_id, scope, created DESC); + +-- ── AI Prompts ────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ai_prompts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + template TEXT NOT NULL, + active BOOLEAN DEFAULT TRUE, + sort_order INTEGER DEFAULT 0, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ai_prompts_slug ON ai_prompts(slug); +CREATE INDEX IF NOT EXISTS idx_ai_prompts_active_sort ON ai_prompts(active, sort_order); + +-- ================================================================ +-- TRIGGERS +-- ================================================================ + +-- Auto-update timestamp trigger for profiles +CREATE OR REPLACE FUNCTION update_updated_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER IF NOT EXISTS trigger_profiles_updated + BEFORE UPDATE ON profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_timestamp(); + +CREATE TRIGGER IF NOT EXISTS trigger_ai_prompts_updated + BEFORE UPDATE ON ai_prompts + FOR EACH ROW + EXECUTE FUNCTION update_updated_timestamp(); + +-- ================================================================ +-- COMMENTS (Documentation) +-- ================================================================ + +COMMENT ON TABLE profiles IS 'User profiles with auth, permissions, and tier system'; +COMMENT ON TABLE sessions IS 'Active auth tokens'; +COMMENT ON TABLE ai_usage IS 'Daily AI call tracking per profile'; +COMMENT ON TABLE weight_log IS 'Weight measurements'; +COMMENT ON TABLE circumference_log IS 'Body circumference measurements (8 points)'; +COMMENT ON TABLE caliper_log IS 'Skinfold measurements with body fat calculations'; +COMMENT ON TABLE nutrition_log IS 'Daily nutrition intake (calories + macros)'; +COMMENT ON TABLE activity_log IS 'Training sessions and activities'; +COMMENT ON TABLE photos IS 'Progress photos'; +COMMENT ON TABLE ai_insights IS 'AI-generated analysis results'; +COMMENT ON TABLE ai_prompts IS 'Configurable AI prompt templates'; + +COMMENT ON COLUMN profiles.tier IS 'Subscription tier: free, basic, premium, selfhosted'; +COMMENT ON COLUMN profiles.trial_ends_at IS 'Trial expiration timestamp (14 days from registration)'; +COMMENT ON COLUMN profiles.tier_expires_at IS 'Paid tier expiration timestamp'; +COMMENT ON COLUMN profiles.invited_by IS 'Profile ID of inviter (for beta invitations)'; diff --git a/backend/startup.sh b/backend/startup.sh new file mode 100644 index 0000000..54bd626 --- /dev/null +++ b/backend/startup.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -e + +echo "═══════════════════════════════════════════════════════════" +echo "MITAI JINKENDO - Backend Startup (v9b)" +echo "═══════════════════════════════════════════════════════════" + +# ── PostgreSQL Connection Check ─────────────────────────────── +echo "" +echo "Checking PostgreSQL connection..." + +MAX_RETRIES=30 +RETRY_COUNT=0 + +until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "✗ PostgreSQL not ready after ${MAX_RETRIES} attempts" + echo " Exiting..." + exit 1 + fi + echo " Waiting for PostgreSQL... (attempt $RETRY_COUNT/$MAX_RETRIES)" + sleep 2 +done + +echo "✓ PostgreSQL ready" + +# ── Schema Initialization ────────────────────────────────────── +echo "" +echo "Checking database schema..." + +# Check if profiles table exists +TABLE_EXISTS=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -tAc \ + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='profiles'") + +if [ "$TABLE_EXISTS" = "0" ]; then + echo " Schema not found, initializing..." + PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -f /app/schema.sql + echo "✓ Schema loaded from schema.sql" +else + echo "✓ Schema already exists" +fi + +# ── Auto-Migration (SQLite → PostgreSQL) ─────────────────────── +echo "" +echo "Checking for SQLite data migration..." + +SQLITE_DB="/app/data/bodytrack.db" +PROFILE_COUNT=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -tAc \ + "SELECT COUNT(*) FROM profiles") + +if [ -f "$SQLITE_DB" ] && [ "$PROFILE_COUNT" = "0" ]; then + echo " SQLite database found and PostgreSQL is empty" + echo " Starting automatic migration..." + python /app/migrate_to_postgres.py + echo "✓ Migration completed" +elif [ -f "$SQLITE_DB" ] && [ "$PROFILE_COUNT" != "0" ]; then + echo "⚠ SQLite DB exists but PostgreSQL already has $PROFILE_COUNT profiles" + echo " Skipping migration (already migrated)" +elif [ ! -f "$SQLITE_DB" ]; then + echo "✓ No SQLite database found (fresh install or already migrated)" +else + echo "✓ No migration needed" +fi + +# ── Start Application ────────────────────────────────────────── +echo "" +echo "═══════════════════════════════════════════════════════════" +echo "Starting FastAPI application..." +echo "═══════════════════════════════════════════════════════════" +echo "" + +exec uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index af0ee02..3d95cb1 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -1,24 +1,55 @@ services: + postgres-dev: + image: postgres:16-alpine + container_name: dev-mitai-postgres + restart: unless-stopped + environment: + POSTGRES_DB: mitai_dev + POSTGRES_USER: mitai_dev + POSTGRES_PASSWORD: dev_password_change_me + volumes: + - mitai_dev_postgres_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mitai_dev"] + interval: 10s + timeout: 5s + retries: 5 + backend: build: ./backend container_name: dev-mitai-api restart: unless-stopped ports: - "8099:8000" + depends_on: + postgres-dev: + condition: service_healthy volumes: - - bodytrack_bodytrack-data:/app/data - bodytrack_bodytrack-photos:/app/photos environment: + # Database + - DB_HOST=postgres-dev + - DB_PORT=5432 + - DB_NAME=mitai_dev + - DB_USER=mitai_dev + - DB_PASSWORD=dev_password_change_me + + # AI - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + + # Email - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM} + + # App - APP_URL=${APP_URL_DEV:-https://dev.mitai.jinkendo.de} - - DATA_DIR=/app/data - PHOTOS_DIR=/app/photos - ALLOWED_ORIGINS=${ALLOWED_ORIGINS_DEV:-*} - ENVIRONMENT=development @@ -33,7 +64,6 @@ services: - backend volumes: - bodytrack_bodytrack-data: - external: true + mitai_dev_postgres_data: bodytrack_bodytrack-photos: external: true diff --git a/docker-compose.yml b/docker-compose.yml index a588a84..4f5378c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,55 @@ services: + postgres: + image: postgres:16-alpine + container_name: mitai-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-mitai} + POSTGRES_USER: ${DB_USER:-mitai} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - mitai_postgres_data:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-mitai}"] + interval: 10s + timeout: 5s + retries: 5 + backend: build: ./backend container_name: mitai-api restart: unless-stopped ports: - "8002:8000" + depends_on: + postgres: + condition: service_healthy volumes: - - bodytrack_bodytrack-data:/app/data - bodytrack_bodytrack-photos:/app/photos environment: + # Database + - DB_HOST=${DB_HOST:-postgres} + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_NAME:-mitai} + - DB_USER=${DB_USER:-mitai} + - DB_PASSWORD=${DB_PASSWORD} + + # AI - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + + # Email - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM} + + # App - APP_URL=${APP_URL} - - DATA_DIR=/app/data - PHOTOS_DIR=/app/photos - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} - ENVIRONMENT=production @@ -33,7 +64,7 @@ services: - backend volumes: - bodytrack_bodytrack-data: - external: true + mitai_postgres_data: + name: mitai_postgres_data bodytrack_bodytrack-photos: external: true -- 2.43.0 From 56edd16368f453adbd686aa3742223356e23d836 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 09:07:08 +0100 Subject: [PATCH 04/34] small update --- .env.example | 2 +- docker-compose.yml | 39 ++++----------------------------------- 2 files changed, 5 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index efafe9f..6cc4aa5 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ DB_HOST=postgres DB_PORT=5432 DB_NAME=mitai DB_USER=mitai -DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD_HERE +DB_PASSWORD=mitaiDB-PostgreSQL-Neckar-strong # ── KI ───────────────────────────────────────────────────────── # OpenRouter (empfohlen): diff --git a/docker-compose.yml b/docker-compose.yml index 4f5378c..a588a84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,55 +1,24 @@ services: - postgres: - image: postgres:16-alpine - container_name: mitai-postgres - restart: unless-stopped - environment: - POSTGRES_DB: ${DB_NAME:-mitai} - POSTGRES_USER: ${DB_USER:-mitai} - POSTGRES_PASSWORD: ${DB_PASSWORD} - volumes: - - mitai_postgres_data:/var/lib/postgresql/data - ports: - - "127.0.0.1:5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-mitai}"] - interval: 10s - timeout: 5s - retries: 5 - backend: build: ./backend container_name: mitai-api restart: unless-stopped ports: - "8002:8000" - depends_on: - postgres: - condition: service_healthy volumes: + - bodytrack_bodytrack-data:/app/data - bodytrack_bodytrack-photos:/app/photos environment: - # Database - - DB_HOST=${DB_HOST:-postgres} - - DB_PORT=${DB_PORT:-5432} - - DB_NAME=${DB_NAME:-mitai} - - DB_USER=${DB_USER:-mitai} - - DB_PASSWORD=${DB_PASSWORD} - - # AI - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - # Email - SMTP_HOST=${SMTP_HOST} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM} - - # App - APP_URL=${APP_URL} + - DATA_DIR=/app/data - PHOTOS_DIR=/app/photos - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} - ENVIRONMENT=production @@ -64,7 +33,7 @@ services: - backend volumes: - mitai_postgres_data: - name: mitai_postgres_data + bodytrack_bodytrack-data: + external: true bodytrack_bodytrack-photos: external: true -- 2.43.0 From ec3c279e11a870f4fe835198b705576f7035efb5 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 09:48:23 +0100 Subject: [PATCH 05/34] fix: use German APT mirror for better connectivity - Switch from deb.debian.org to ftp.de.debian.org (33% packet loss observed) - Add APT retry logic (3 attempts) for flaky connections - Fixes deployment timeout on backend build (postgresql-client install) Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/Dockerfile b/backend/Dockerfile index 2caeaf5..e38531c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,10 @@ FROM python:3.12-slim +# Use German mirror for better connectivity and add retry logic +RUN echo "deb http://ftp.de.debian.org/debian bookworm main" > /etc/apt/sources.list && \ + echo "deb http://ftp.de.debian.org/debian bookworm-updates main" >> /etc/apt/sources.list && \ + echo "Acquire::Retries \"3\";" > /etc/apt/apt.conf.d/80-retries + # Install PostgreSQL client for psql (needed for startup.sh) RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/* -- 2.43.0 From 67b00b941e5b130a54628a9bb577fbe869dcfd6e Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 09:52:44 +0100 Subject: [PATCH 06/34] fix: force IPv4 and add aggressive timeouts for APT - Force IPv4 (IPv6 shows 33% packet loss) - Increase retries to 5 - Add 10s timeouts to fail fast and retry - Previous fix improved from 1630s to 78s but still hangs Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index e38531c..6c5e3a3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,9 +1,12 @@ FROM python:3.12-slim -# Use German mirror for better connectivity and add retry logic -RUN echo "deb http://ftp.de.debian.org/debian bookworm main" > /etc/apt/sources.list && \ - echo "deb http://ftp.de.debian.org/debian bookworm-updates main" >> /etc/apt/sources.list && \ - echo "Acquire::Retries \"3\";" > /etc/apt/apt.conf.d/80-retries +# Force IPv4 and use German mirror with aggressive retry/timeout settings +RUN echo "Acquire::ForceIPv4 \"true\";" > /etc/apt/apt.conf.d/99force-ipv4 && \ + echo "Acquire::Retries \"5\";" > /etc/apt/apt.conf.d/80-retries && \ + echo "Acquire::http::Timeout \"10\";" >> /etc/apt/apt.conf.d/80-retries && \ + echo "Acquire::ftp::Timeout \"10\";" >> /etc/apt/apt.conf.d/80-retries && \ + echo "deb http://ftp.de.debian.org/debian bookworm main" > /etc/apt/sources.list && \ + echo "deb http://ftp.de.debian.org/debian bookworm-updates main" >> /etc/apt/sources.list # Install PostgreSQL client for psql (needed for startup.sh) RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/* -- 2.43.0 From 627eb8e26553c2826395f2841c05eb1fce3f2c17 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 10:01:19 +0100 Subject: [PATCH 07/34] fix: replace psql with Python for DB checks (no apt-get needed!) - Remove postgresql-client installation (causes 150s+ hangs due to network) - Add db_init.py: Pure Python PostgreSQL checks using psycopg2-binary - Simplify startup.sh: Call Python script instead of psql commands - Build should now complete in <30s instead of hanging This fixes the deployment timeout issue by avoiding APT network problems entirely. Co-Authored-By: Claude Opus 4.6 --- backend/Dockerfile | 11 +--- backend/db_init.py | 135 +++++++++++++++++++++++++++++++++++++++++++++ backend/startup.sh | 64 ++------------------- 3 files changed, 141 insertions(+), 69 deletions(-) create mode 100644 backend/db_init.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 6c5e3a3..e0b5705 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,15 +1,6 @@ FROM python:3.12-slim -# Force IPv4 and use German mirror with aggressive retry/timeout settings -RUN echo "Acquire::ForceIPv4 \"true\";" > /etc/apt/apt.conf.d/99force-ipv4 && \ - echo "Acquire::Retries \"5\";" > /etc/apt/apt.conf.d/80-retries && \ - echo "Acquire::http::Timeout \"10\";" >> /etc/apt/apt.conf.d/80-retries && \ - echo "Acquire::ftp::Timeout \"10\";" >> /etc/apt/apt.conf.d/80-retries && \ - echo "deb http://ftp.de.debian.org/debian bookworm main" > /etc/apt/sources.list && \ - echo "deb http://ftp.de.debian.org/debian bookworm-updates main" >> /etc/apt/sources.list - -# Install PostgreSQL client for psql (needed for startup.sh) -RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/* +# No system packages needed - we use Python (psycopg2-binary) for PostgreSQL checks WORKDIR /app diff --git a/backend/db_init.py b/backend/db_init.py new file mode 100644 index 0000000..86481cd --- /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: + import migrate_to_postgres + print("✓ Migration completed") + 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/startup.sh b/backend/startup.sh index 54bd626..5ee2bb4 100644 --- a/backend/startup.sh +++ b/backend/startup.sh @@ -1,66 +1,12 @@ #!/bin/bash set -e -echo "═══════════════════════════════════════════════════════════" -echo "MITAI JINKENDO - Backend Startup (v9b)" -echo "═══════════════════════════════════════════════════════════" +# Run database initialization with Python (no psql needed!) +python /app/db_init.py -# ── PostgreSQL Connection Check ─────────────────────────────── -echo "" -echo "Checking PostgreSQL connection..." - -MAX_RETRIES=30 -RETRY_COUNT=0 - -until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do - RETRY_COUNT=$((RETRY_COUNT + 1)) - if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then - echo "✗ PostgreSQL not ready after ${MAX_RETRIES} attempts" - echo " Exiting..." - exit 1 - fi - echo " Waiting for PostgreSQL... (attempt $RETRY_COUNT/$MAX_RETRIES)" - sleep 2 -done - -echo "✓ PostgreSQL ready" - -# ── Schema Initialization ────────────────────────────────────── -echo "" -echo "Checking database schema..." - -# Check if profiles table exists -TABLE_EXISTS=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -tAc \ - "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='profiles'") - -if [ "$TABLE_EXISTS" = "0" ]; then - echo " Schema not found, initializing..." - PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -f /app/schema.sql - echo "✓ Schema loaded from schema.sql" -else - echo "✓ Schema already exists" -fi - -# ── Auto-Migration (SQLite → PostgreSQL) ─────────────────────── -echo "" -echo "Checking for SQLite data migration..." - -SQLITE_DB="/app/data/bodytrack.db" -PROFILE_COUNT=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -tAc \ - "SELECT COUNT(*) FROM profiles") - -if [ -f "$SQLITE_DB" ] && [ "$PROFILE_COUNT" = "0" ]; then - echo " SQLite database found and PostgreSQL is empty" - echo " Starting automatic migration..." - python /app/migrate_to_postgres.py - echo "✓ Migration completed" -elif [ -f "$SQLITE_DB" ] && [ "$PROFILE_COUNT" != "0" ]; then - echo "⚠ SQLite DB exists but PostgreSQL already has $PROFILE_COUNT profiles" - echo " Skipping migration (already migrated)" -elif [ ! -f "$SQLITE_DB" ]; then - echo "✓ No SQLite database found (fresh install or already migrated)" -else - echo "✓ No migration needed" +if [ $? -ne 0 ]; then + echo "✗ Database initialization failed" + exit 1 fi # ── Start Application ────────────────────────────────────────── -- 2.43.0 From ad433a470f4c56909de918fc00daefd0807b44c0 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 11:59:09 +0100 Subject: [PATCH 08/34] fix: PostgreSQL trigger syntax (remove IF NOT EXISTS) PostgreSQL does not support IF NOT EXISTS for CREATE TRIGGER. Use DROP TRIGGER IF EXISTS before CREATE TRIGGER instead. Fixes: Backend crash loop due to schema.sql syntax error on line 231 Co-Authored-By: Claude Opus 4.6 --- backend/schema.sql | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/schema.sql b/backend/schema.sql index 56300f6..58139d4 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -228,12 +228,14 @@ BEGIN END; $$ LANGUAGE plpgsql; -CREATE TRIGGER IF NOT EXISTS trigger_profiles_updated +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(); -CREATE TRIGGER IF NOT EXISTS trigger_ai_prompts_updated +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(); -- 2.43.0 From 6f0f1ae9b4f14520a640dcf1dd888fe22cda14fd Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:04:36 +0100 Subject: [PATCH 09/34] fix: send 'password' instead of 'pin' in login request Frontend was sending {email, pin} but backend expects {email, password}. This caused 422 Unprocessable Entity errors. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/LoginScreen.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) } -- 2.43.0 From d2c578de839d5f9de80569e688a1bec6e0c43679 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:11:37 +0100 Subject: [PATCH 10/34] fix: add missing /app/data volume for SQLite migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data volume was missing in dev environment, preventing automatic SQLite → PostgreSQL migration. The SQLite database (bodytrack.db) was not accessible to the container, so migration was skipped. This fixes the "No SQLite database found" message when data exists. Co-Authored-By: Claude Opus 4.6 --- docker-compose.dev-env.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index 3d95cb1..58da367 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -27,6 +27,7 @@ services: postgres-dev: condition: service_healthy volumes: + - bodytrack_bodytrack-data:/app/data - bodytrack_bodytrack-photos:/app/photos environment: # Database @@ -65,5 +66,7 @@ services: volumes: mitai_dev_postgres_data: + bodytrack_bodytrack-data: + external: true bodytrack_bodytrack-photos: external: true -- 2.43.0 From 39a7b1be78321d0e589900aed71bc8ec72b6c488 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:14:25 +0100 Subject: [PATCH 11/34] feat: add PostgreSQL to production setup (v9b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares production for SQLite → PostgreSQL migration: - Add postgres service (mitai-db, port 5432) - Add DB environment variables to backend - Backend depends on postgres health check - Uses startup.sh for automatic migration Migration strategy: 1. SQLite data in /app/data/bodytrack.db is preserved (volume mounted) 2. On first start with empty PostgreSQL: automatic migration 3. Migration is safe: checks if profiles table is empty before migrating 4. After migration: all new data goes to PostgreSQL IMPORTANT: Set DB_PASSWORD in .env before deploying! Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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: -- 2.43.0 From d15ec056b4a4141aa091b543f8d9945711d6c3d3 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:19:51 +0100 Subject: [PATCH 12/34] fix: actually call migration function (was only importing) Bug: db_init.py was importing migrate_to_postgres but not calling main(). Result: Migration appeared successful but no data was migrated (0 users). Fix: Import and call migrate_to_postgres.main() Co-Authored-By: Claude Opus 4.6 --- backend/db_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/db_init.py b/backend/db_init.py index 86481cd..67283d2 100644 --- a/backend/db_init.py +++ b/backend/db_init.py @@ -119,8 +119,8 @@ if __name__ == "__main__": print(" Starting automatic migration...") # Import and run migration try: - import migrate_to_postgres - print("✓ Migration completed") + from migrate_to_postgres import main as migrate + migrate() except Exception as e: print(f"✗ Migration failed: {e}") sys.exit(1) -- 2.43.0 From 7758bbf12ef4bb605d0ca34377c2ff240eb12933 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:23:13 +0100 Subject: [PATCH 13/34] fix: add missing meas_id column to photos table SQLite schema (v9a) has meas_id in photos table, but PostgreSQL schema (v9b) was missing it. This caused migration to fail. Added meas_id as nullable UUID column for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- backend/schema.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/schema.sql b/backend/schema.sql index 58139d4..a921b78 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -177,6 +177,7 @@ CREATE INDEX IF NOT EXISTS idx_activity_profile_date ON activity_log(profile_id, 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 -- 2.43.0 From 124df019833055f7787d82590a399696d54374ce Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:32:34 +0100 Subject: [PATCH 14/34] fix: convert empty date strings to NULL in migration PostgreSQL DATE type doesn't accept empty strings (''). Convert empty/whitespace date values to NULL during migration. Fixes: invalid input syntax for type date: "" Co-Authored-By: Claude Opus 4.6 --- backend/migrate_to_postgres.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/migrate_to_postgres.py b/backend/migrate_to_postgres.py index b6a6dab..41a9371 100644 --- a/backend/migrate_to_postgres.py +++ b/backend/migrate_to_postgres.py @@ -90,6 +90,10 @@ def convert_value(value: Any, column: str, table: str) -> Any: 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) -- 2.43.0 From 9fbedb6c4b614174682bb01193f7ca5ffee7069d Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:42:46 +0100 Subject: [PATCH 15/34] fix: use RealDictCursor for PostgreSQL row access All conn.cursor() calls replaced with get_cursor(conn) to enable dict-like row access (prof['pin_hash'] instead of prof[column_index]). This fixes KeyError when accessing PostgreSQL query results. Fixes: 'tuple' object has no attribute '__getitem__' with string keys Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 114 ++++++++++++++++++++++++------------------------ 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/backend/main.py b/backend/main.py index 103ada8..7556af1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,7 @@ from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from starlette.requests import Request -from db import get_db, r2d +from db import get_db, get_cursor, r2d DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) @@ -51,7 +51,7 @@ 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: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1") row = cur.fetchone() if row: return row['id'] @@ -134,7 +134,7 @@ def make_token() -> str: def get_session(token: str): if not token: return None with get_db() as conn: - cur = conn.cursor() + 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 " @@ -157,7 +157,7 @@ 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: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT * FROM profiles ORDER BY created") rows = cur.fetchall() return [r2d(r) for r in rows] @@ -166,19 +166,19 @@ def list_profiles(session=Depends(require_auth)): def create_profile(p: ProfileCreate, session=Depends(require_auth)): pid = str(uuid.uuid4()) with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""", (pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct)) with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) return r2d(cur.fetchone()) @app.get("/api/profiles/{pid}") def get_profile(pid: str, session=Depends(require_auth)): with get_db() as conn: - cur = conn.cursor() + 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") @@ -189,7 +189,7 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): with get_db() as conn: data = {k:v for k,v in p.model_dump().items() if v is not None} data['updated'] = datetime.now().isoformat() - cur = conn.cursor() + cur = get_cursor(conn) cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s", list(data.values())+[pid]) return get_profile(pid, session) @@ -197,7 +197,7 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): @app.delete("/api/profiles/{pid}") def delete_profile(pid: str, session=Depends(require_auth)): with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT COUNT(*) FROM profiles") count = cur.fetchone()[0] if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden") @@ -222,7 +222,7 @@ def update_active_profile(p: ProfileUpdate, x_profile_id: Optional[str] = Header def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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()] @@ -231,7 +231,7 @@ def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None) def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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: @@ -247,7 +247,7 @@ def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=Non def update_weight(wid: str, e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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} @@ -256,7 +256,7 @@ def update_weight(wid: str, e: WeightEntry, x_profile_id: Optional[str]=Header(d def delete_weight(wid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("DELETE FROM weight_log WHERE id=%s AND profile_id=%s", (wid,pid)) return {"ok":True} @@ -264,7 +264,7 @@ def delete_weight(wid: str, x_profile_id: Optional[str]=Header(default=None), se def weight_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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} @@ -278,7 +278,7 @@ 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: - cur = conn.cursor() + 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()] @@ -287,7 +287,7 @@ def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None), def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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() @@ -310,7 +310,7 @@ 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() - cur = conn.cursor() + 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} @@ -319,7 +319,7 @@ def update_circ(eid: str, e: CircumferenceEntry, x_profile_id: Optional[str]=Hea def delete_circ(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("DELETE FROM circumference_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} @@ -328,7 +328,7 @@ 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: - cur = conn.cursor() + 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()] @@ -337,7 +337,7 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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() @@ -362,7 +362,7 @@ 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() - cur = conn.cursor() + 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} @@ -371,7 +371,7 @@ def update_caliper(eid: str, e: CaliperEntry, x_profile_id: Optional[str]=Header def delete_caliper(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("DELETE FROM caliper_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} @@ -380,7 +380,7 @@ 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: - cur = conn.cursor() + 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()] @@ -391,7 +391,7 @@ 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: - cur = conn.cursor() + 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) @@ -406,7 +406,7 @@ 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() - cur = conn.cursor() + 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} @@ -415,7 +415,7 @@ def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Head def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("DELETE FROM activity_log WHERE id=%s AND profile_id=%s", (eid,pid)) return {"ok":True} @@ -423,7 +423,7 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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()] @@ -448,7 +448,7 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional reader = csv.DictReader(io.StringIO(text)) inserted = skipped = 0 with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) for row in reader: wtype = row.get('Workout Type','').strip() start = row.get('Start','').strip() @@ -492,7 +492,7 @@ async def upload_photo(file: UploadFile=File(...), date: str="", path = PHOTOS_DIR / f"{fid}{ext}" async with aiofiles.open(path,'wb') as f: await f.write(await file.read()) with get_db() as conn: - cur = conn.cursor() + 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} @@ -500,7 +500,7 @@ async def upload_photo(file: UploadFile=File(...), date: str="", @app.get("/api/photos/{fid}") def get_photo(fid: str, session: dict=Depends(require_auth)): with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT path FROM photos WHERE id=%s", (fid,)) row = cur.fetchone() if not row: raise HTTPException(404) @@ -510,7 +510,7 @@ def get_photo(fid: str, session: dict=Depends(require_auth)): def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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()] @@ -546,7 +546,7 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona count+=1 inserted=0 with get_db() as conn: - cur = conn.cursor() + 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) @@ -565,7 +565,7 @@ 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: - cur = conn.cursor() + 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()] @@ -574,7 +574,7 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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,)) @@ -602,7 +602,7 @@ 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: - cur = conn.cursor() + 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 [] @@ -622,7 +622,7 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N def get_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT COUNT(*) FROM weight_log WHERE profile_id=%s",(pid,)) weight_count = cur.fetchone()[0] cur.execute("SELECT COUNT(*) FROM circumference_log WHERE profile_id=%s",(pid,)) @@ -648,7 +648,7 @@ import httpx, json def get_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + cur = 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 @@ -658,14 +658,14 @@ def get_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=None), def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + cur = 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 = conn.cursor() + 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']: @@ -685,7 +685,7 @@ def inc_ai_usage(pid: str): """Increment AI usage counter for today.""" today = datetime.now().date().isoformat() with get_db() as conn: - cur = conn.cursor() + cur = 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: @@ -697,7 +697,7 @@ def inc_ai_usage(pid: str): def _get_profile_data(pid: str): """Fetch all relevant data for AI analysis.""" with get_db() as conn: - cur = conn.cursor() + 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,)) @@ -831,7 +831,7 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa # Get prompt template with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=1", (slug,)) prompt_row = cur.fetchone() if not prompt_row: @@ -872,7 +872,7 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa # Save insight with get_db() as conn: - cur = conn.cursor() + 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)) @@ -891,7 +891,7 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses # Stage 1: Parallel JSON analyses with get_db() as conn: - cur = conn.cursor() + 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=1") stage1_prompts = [r2d(r) for r in cur.fetchall()] @@ -936,7 +936,7 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses vars['stage1_activity'] = json.dumps(stage1_results.get('pipeline_activity', {}), ensure_ascii=False) with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=1") synth_row = cur.fetchone() if not synth_row: @@ -973,7 +973,7 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses prof = data['profile'] if prof.get('goal_weight') or prof.get('goal_bf_pct'): with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_goals' AND active=1") goals_row = cur.fetchone() if goals_row: @@ -1008,7 +1008,7 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses # Save as 'gesamt' scope with get_db() as conn: - cur = conn.cursor() + 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)) @@ -1020,7 +1020,7 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses def list_prompts(session: dict=Depends(require_auth)): """List all available AI prompts.""" with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT * FROM ai_prompts WHERE active=1 AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order") return [r2d(r) for r in cur.fetchall()] @@ -1029,7 +1029,7 @@ def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict """Get AI usage stats for current profile.""" pid = get_pid(x_profile_id) with get_db() as conn: - cur = conn.cursor() + 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 @@ -1066,7 +1066,7 @@ class PasswordResetConfirm(BaseModel): async def login(req: LoginRequest, request: Request): """Login with email + password.""" with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),)) prof = cur.fetchone() if not prof: @@ -1101,7 +1101,7 @@ def logout(x_auth_token: Optional[str]=Header(default=None)): """Logout (delete session).""" if x_auth_token: with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("DELETE FROM sessions WHERE token=%s", (x_auth_token,)) return {"ok": True} @@ -1117,7 +1117,7 @@ async def password_reset_request(req: PasswordResetRequest, request: Request): """Request password reset email.""" email = req.email.lower().strip() with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,)) prof = cur.fetchone() if not prof: @@ -1173,7 +1173,7 @@ Dein Mitai Jinkendo Team def password_reset_confirm(req: PasswordResetConfirm): """Confirm password reset with token.""" with get_db() as conn: - cur = conn.cursor() + 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() @@ -1198,7 +1198,7 @@ class AdminProfileUpdate(BaseModel): def admin_list_profiles(session: dict=Depends(require_admin)): """Admin: List all profiles with stats.""" with get_db() as conn: - cur = conn.cursor() + cur = get_cursor(conn) cur.execute("SELECT * FROM profiles ORDER BY created") profs = [r2d(r) for r in cur.fetchall()] @@ -1224,7 +1224,7 @@ def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depen if not updates: return {"ok": True} - cur = conn.cursor() + 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]) @@ -1267,7 +1267,7 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D # Check export permission with get_db() as conn: - cur = conn.cursor() + 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']: @@ -1282,7 +1282,7 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D # Weight with get_db() as conn: - cur = conn.cursor() + 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"{r['weight']}kg", r['note'] or ""]) -- 2.43.0 From 79a951ce924e3f9bbe7cc7da431ac97cfdb0d4e6 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:47:01 +0100 Subject: [PATCH 16/34] fix: use column names for COUNT queries with RealDictCursor RealDictCursor returns dicts, not tuples. Cannot use [0] for index access. Changed all COUNT(*) to COUNT(*) as count and access via ['count']. Fixes: KeyError: 0 on cur.fetchone()[0] Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/main.py b/backend/main.py index 7556af1..ca25b9e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -198,8 +198,8 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): def delete_profile(pid: str, session=Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT COUNT(*) FROM profiles") - count = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) as count FROM profiles") + count = cur.fetchone()['count'] if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden") for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']: cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,)) @@ -623,16 +623,16 @@ def get_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=De pid = get_pid(x_profile_id) with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT COUNT(*) FROM weight_log WHERE profile_id=%s",(pid,)) - weight_count = cur.fetchone()[0] - cur.execute("SELECT COUNT(*) FROM circumference_log WHERE profile_id=%s",(pid,)) - circ_count = cur.fetchone()[0] - cur.execute("SELECT COUNT(*) FROM caliper_log WHERE profile_id=%s",(pid,)) - caliper_count = cur.fetchone()[0] - cur.execute("SELECT COUNT(*) FROM nutrition_log WHERE profile_id=%s",(pid,)) - nutrition_count = cur.fetchone()[0] - cur.execute("SELECT COUNT(*) FROM activity_log WHERE profile_id=%s",(pid,)) - activity_count = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) as count FROM weight_log WHERE profile_id=%s",(pid,)) + weight_count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM circumference_log WHERE profile_id=%s",(pid,)) + circ_count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM caliper_log WHERE profile_id=%s",(pid,)) + caliper_count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM nutrition_log WHERE profile_id=%s",(pid,)) + nutrition_count = cur.fetchone()['count'] + cur.execute("SELECT COUNT(*) as count FROM activity_log WHERE profile_id=%s",(pid,)) + activity_count = cur.fetchone()['count'] return { "weight_count": weight_count, "circ_count": circ_count, @@ -1204,10 +1204,10 @@ def admin_list_profiles(session: dict=Depends(require_admin)): for p in profs: pid = p['id'] - cur.execute("SELECT COUNT(*) FROM weight_log WHERE profile_id=%s", (pid,)) - p['weight_count'] = cur.fetchone()[0] - cur.execute("SELECT COUNT(*) FROM ai_insights WHERE profile_id=%s", (pid,)) - p['ai_insights_count'] = cur.fetchone()[0] + 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)) -- 2.43.0 From 8390c7f5100252956f97c0dc05aa6d7cce525d2c Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 12:54:25 +0100 Subject: [PATCH 17/34] feat: add missing API endpoints - Add GET /api/insights/latest (returns latest 10 insights) - Add GET /api/auth/status (health check endpoint) These endpoints were called by frontend but returned 404, causing uncaught promise errors that blocked page loading. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/main.py b/backend/main.py index ca25b9e..c16dee1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -644,6 +644,16 @@ def get_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=De # ── AI Insights ─────────────────────────────────────────────────────────────── import httpx, json +@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) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC LIMIT 10", (pid,)) + rows = cur.fetchall() + return [r2d(r) for r in rows] + @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) @@ -1111,6 +1121,11 @@ def get_me(session: dict=Depends(require_auth)): pid = session['profile_id'] return get_profile(pid, session) +@app.get("/api/auth/status") +def auth_status(): + """Health check endpoint.""" + return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"} + @app.post("/api/auth/password-reset-request") @limiter.limit("3/minute") async def password_reset_request(req: PasswordResetRequest, request: Request): -- 2.43.0 From 36f334aba7aa8e8bb9a7a456d29bf8b8aaad87d7 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 13:55:14 +0100 Subject: [PATCH 18/34] fix: PostgreSQL boolean syntax in prompts queries - Change WHERE active=1 to WHERE active=true (PostgreSQL uses boolean) - Change endpoint from /api/ai/prompts to /api/prompts (simpler path) - Fixed 5 occurrences across prompt-related queries This fixes the issue where no prompts were returned, causing empty prompt list in Admin and no AI analysis options. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/main.py b/backend/main.py index c16dee1..bc2a169 100644 --- a/backend/main.py +++ b/backend/main.py @@ -842,7 +842,7 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa # Get prompt template with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT * FROM ai_prompts WHERE slug=%s AND active=1", (slug,)) + 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") @@ -902,7 +902,7 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses # 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=1") + cur.execute("SELECT slug, template FROM ai_prompts WHERE slug LIKE 'pipeline_%' AND slug NOT IN ('pipeline_synthesis','pipeline_goals') AND active=true") stage1_prompts = [r2d(r) for r in cur.fetchall()] stage1_results = {} @@ -947,7 +947,7 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=1") + 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") @@ -984,7 +984,7 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses 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=1") + 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) @@ -1026,12 +1026,12 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses inc_ai_usage(pid) return {"scope": "gesamt", "content": final_content, "stage1": stage1_results} -@app.get("/api/ai/prompts") +@app.get("/api/prompts") def list_prompts(session: dict=Depends(require_auth)): """List all available AI prompts.""" with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT * FROM ai_prompts WHERE active=1 AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order") + 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.get("/api/ai/usage") -- 2.43.0 From 3d58a2db8e26d48d3aef2bc2e150614c73e8bfff Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 15:26:57 +0100 Subject: [PATCH 19/34] fix: add missing /api/insights endpoints - Add GET /api/insights (returns all insights for profile) - Add DELETE /api/insights/{id} (delete by ID, not scope) - Frontend Analysis.jsx needs these endpoints to load/delete insights Fixes 404 error preventing prompts from displaying. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/main.py b/backend/main.py index bc2a169..a097e54 100644 --- a/backend/main.py +++ b/backend/main.py @@ -644,6 +644,16 @@ def get_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=De # ── AI Insights ─────────────────────────────────────────────────────────────── import httpx, json +@app.get("/api/insights") +def get_all_insights(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Get all AI insights for profile.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) + rows = cur.fetchall() + return [r2d(r) for r in rows] + @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.""" @@ -664,6 +674,15 @@ def get_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=None), if not row: return None return r2d(row) +@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) -- 2.43.0 From 1db780858bc3585b71f5d34fc877804f8387f1b9 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 17:07:41 +0100 Subject: [PATCH 20/34] fix: align all API endpoints between frontend and backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed 11 critical endpoint mismatches found during codebase audit. **Renamed Endpoints (consistency):** - /api/ai/analyze/{slug} → /api/insights/run/{slug} - /api/ai/analyze-pipeline → /api/insights/pipeline - /api/auth/password-reset-request → /api/auth/forgot-password - /api/auth/password-reset-confirm → /api/auth/reset-password - /api/admin/test-email → /api/admin/email/test **Added Missing Endpoints:** - POST /api/auth/pin (change PIN/password for current user) - PUT /api/admin/profiles/{id}/permissions (set permissions) - PUT /api/admin/profiles/{id}/email (set email) - PUT /api/admin/profiles/{id}/pin (admin set PIN) - GET /api/admin/email/status (check SMTP config) - PUT /api/prompts/{id} (edit prompt templates, admin only) - GET /api/export/json (export all data as JSON) - GET /api/export/zip (export data + photos as ZIP) **Updated:** - Added imports: json, zipfile, Response - Fixed admin email test endpoint to accept dict body All frontend API calls now have matching backend implementations. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 235 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 227 insertions(+), 8 deletions(-) diff --git a/backend/main.py b/backend/main.py index a097e54..4c09098 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,11 @@ -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 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 aiofiles import bcrypt @@ -852,7 +852,7 @@ def _prepare_template_vars(data: dict) -> dict: return vars -@app.post("/api/ai/analyze/{slug}") +@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) @@ -909,7 +909,7 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa inc_ai_usage(pid) return {"scope": slug, "content": content} -@app.post("/api/ai/analyze-pipeline") +@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) @@ -1053,6 +1053,32 @@ def list_prompts(session: dict=Depends(require_auth)): 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') + values.append(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.""" @@ -1145,7 +1171,22 @@ def auth_status(): """Health check endpoint.""" return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"} -@app.post("/api/auth/password-reset-request") +@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: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) + + return {"ok": True} + +@app.post("/api/auth/forgot-password") @limiter.limit("3/minute") async def password_reset_request(req: PasswordResetRequest, request: Request): """Request password reset email.""" @@ -1203,7 +1244,7 @@ Dein Mitai Jinkendo Team return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} -@app.post("/api/auth/password-reset-confirm") +@app.post("/api/auth/reset-password") def password_reset_confirm(req: PasswordResetConfirm): """Confirm password reset with token.""" with get_db() as conn: @@ -1264,9 +1305,79 @@ def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depen return {"ok": True} -@app.post("/api/admin/test-email") -def admin_test_email(email: str, session: dict=Depends(require_admin)): +@app.put("/api/admin/profiles/{pid}/permissions") +def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile permissions.""" + with get_db() as conn: + cur = get_cursor(conn) + updates = [] + values = [] + if 'ai_enabled' in data: + updates.append('ai_enabled=%s') + values.append(data['ai_enabled']) + if 'ai_limit_day' in data: + updates.append('ai_limit_day=%s') + values.append(data['ai_limit_day']) + if 'export_enabled' in data: + updates.append('export_enabled=%s') + values.append(data['export_enabled']) + if 'role' in data: + updates.append('role=%s') + values.append(data['role']) + + if updates: + cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid]) + + return {"ok": True} + +@app.put("/api/admin/profiles/{pid}/email") +def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile email.""" + email = data.get('email', '').strip().lower() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid)) + + return {"ok": True} + +@app.put("/api/admin/profiles/{pid}/pin") +def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile PIN/password.""" + new_pin = data.get('pin', '') + if len(new_pin) < 4: + raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") + + new_hash = hash_pin(new_pin) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) + + return {"ok": True} + +@app.get("/api/admin/email/status") +def admin_email_status(session: dict=Depends(require_admin)): + """Admin: Check email configuration status.""" + smtp_host = os.getenv("SMTP_HOST") + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + app_url = os.getenv("APP_URL", "http://localhost:3002") + + configured = bool(smtp_host and smtp_user and smtp_pass) + + return { + "configured": configured, + "smtp_host": smtp_host or "", + "smtp_user": smtp_user or "", + "app_url": app_url + } + +@app.post("/api/admin/email/test") +def admin_test_email(data: dict, session: dict=Depends(require_admin)): """Admin: Send test email.""" + email = data.get('to', '') + if not email: + raise HTTPException(400, "E-Mail-Adresse fehlt") + try: import smtplib from email.mime.text import MIMEText @@ -1348,3 +1459,111 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D 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()] + + json_str = json.dumps(data, indent=2, default=str) + 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 (JSON + photos).""" + 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") + + # Create ZIP in memory + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + # Add JSON 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()] + + zf.writestr("data.json", json.dumps(data, indent=2, default=str)) + + # Add photos if they exist + with get_db() as conn: + cur = get_cursor(conn) + 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(): + zf.write(photo_path, f"photos/{photo['date'] or i}_{photo_path.name}") + + zip_buffer.seek(0) + return StreamingResponse( + iter([zip_buffer.getvalue()]), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.zip"} + ) -- 2.43.0 From 8e25b54cc21d72f3bc289e4ca168becf739214ef Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 21:39:14 +0100 Subject: [PATCH 21/34] docs: update CLAUDE.md for v9b release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated documentation to reflect v9b (PostgreSQL) release: **Version Update:** - v9a → v9b (PostgreSQL Migration complete) - Tech Stack: SQLite → PostgreSQL 16 (Alpine) - 60+ protected endpoints (was 44) **New Features Documented:** - ✅ PostgreSQL migration (auto-migrate from SQLite) - ✅ Export: CSV, JSON, ZIP (with photos) - ✅ Admin: Edit prompts, set email/PIN - ✅ All API endpoints aligned (11 fixes) **Environment Variables:** - Added DB_* variables (PostgreSQL connection) - Added ANTHROPIC_API_KEY (alternative to OpenRouter) **Important Hints:** - Updated: PostgreSQL migrations instead of SQLite safe_alters - Added: RealDictCursor usage for dict-like row access - Added: PostgreSQL boolean syntax (true/false not 1/0) **New Section: v9b Migration – Lessons Learned** - Docker build optimization (removed apt-get) - Empty date string handling - Boolean field conversion - API endpoint consistency audit **Roadmap Adjustment:** - v9c: Tier System (was in v9b) - v9d: OAuth2 Connectors (was in v9c) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 96 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 887af36..56a15dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ |-----------|-------------|---------| | Frontend | React 18 + Vite + PWA | Node 20 | | Backend | FastAPI (Python) | Python 3.12 | -| Datenbank | SQLite (v9a) → PostgreSQL (v9b geplant) | - | +| Datenbank | PostgreSQL 16 (Alpine) | v9b | | Container | Docker + Docker Compose | - | | Webserver | nginx (Reverse Proxy) | Alpine | | Auth | Token-basiert + bcrypt | - | @@ -53,36 +53,38 @@ mitai-jinkendo/ └── CLAUDE.md # Diese Datei ``` -## Aktuelle Version: v9a +## Aktuelle Version: v9b ### Was implementiert ist: - ✅ Multi-User mit E-Mail + Passwort Login (bcrypt) -- ✅ Auth-Middleware auf ALLE Endpoints (44 Endpoints geschützt) +- ✅ 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 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 v9b kommt: -- 🔲 PostgreSQL Migration (aktuell noch SQLite) +### Was in v9c kommt: - 🔲 Selbst-Registrierung mit E-Mail-Bestätigung - 🔲 Freemium Tier-System (free/basic/premium/selfhosted) - 🔲 14-Tage Trial automatisch - 🔲 Einladungslinks für Beta-Nutzer - 🔲 Admin kann Tiers manuell setzen -### Was in v9c kommt: +### Was in v9d kommt: - 🔲 OAuth2-Grundgerüst für Fitness-Connectoren - 🔲 Strava Connector - 🔲 Withings Connector (Waage) @@ -116,20 +118,24 @@ docker compose -f docker-compose.dev-env.yml build --no-cache docker compose -f docker-compose.dev-env.yml up -d ``` -## Datenbank-Schema (SQLite, v9a) +## Datenbank-Schema (PostgreSQL 16, v9b) ### Wichtige Tabellen: -- `profiles` – Nutzer (role, pin_hash/bcrypt, email, auth_type, ai_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 (11 Prompts) - `ai_usage` – KI-Calls pro Tag pro Profil -## Auth-Flow (v9a) +**Schema-Datei:** `backend/schema.sql` (vollständiges PostgreSQL-Schema) +**Migration-Script:** `backend/migrate_to_postgres.py` (SQLite→PostgreSQL, automatisch) + +## Auth-Flow (v9b) ``` Login-Screen → E-Mail + Passwort → Token im localStorage Token → X-Auth-Token Header → Backend require_auth() @@ -146,29 +152,44 @@ SHA256 Passwörter → automatisch zu bcrypt migriert beim Login ## 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 -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 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 (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 Liste +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 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 @@ -437,10 +458,47 @@ def endpoint(x_profile_id: Optional[str] = Header(default=None), ``` -### SQLite neue Spalten hinzufügen +### PostgreSQL Boolean-Syntax ```python -# In _safe_alters Liste hinzufügen (NICHT direkt ALTER TABLE): -_safe_alters = [ - ("profiles", "neue_spalte TEXT DEFAULT NULL"), -] +# ❌ 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 -- 2.43.0 From f7f7f745b1949beb2541b274a2e978276f0511a3 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 21:44:10 +0100 Subject: [PATCH 22/34] fix: convert PostgreSQL Decimal to float for math operations Fixed TypeError when preparing AI prompt template variables. PostgreSQL returns NUMERIC columns as decimal.Decimal, not float. **Fixed in _prepare_template_vars:** - Weight calculations (protein targets, delta) - Nutrition averages (kcal, protein, fat, carbs) - Activity totals (kcal_active) All Decimal values now converted to float before math operations. Error was: "TypeError: unsupported operand type(s) for *: 'decimal.Decimal' and 'float'" Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/main.py b/backend/main.py index 4c09098..1417b6d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -790,7 +790,7 @@ def _prepare_template_vars(data: dict) -> dict: # Weight trend summary if len(weight) >= 2: recent = weight[:30] - delta = recent[0]['weight'] - recent[-1]['weight'] + delta = float(recent[0]['weight']) - float(recent[-1]['weight']) vars['weight_trend'] = f"{len(recent)} Einträge, Δ30d: {delta:+.1f}kg" else: vars['weight_trend'] = "zu wenig Daten" @@ -815,15 +815,15 @@ def _prepare_template_vars(data: dict) -> dict: # Nutrition summary if nutrition: n = len(nutrition) - avg_kcal = sum(d.get('kcal',0) for d in nutrition) / n - avg_prot = sum(d.get('protein_g',0) for d in nutrition) / n + 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(d.get('fat_g',0) for d in nutrition) / n,1) - vars['carb_avg'] = round(sum(d.get('carbs_g',0) for d in nutrition) / n,1) + 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" @@ -835,13 +835,14 @@ def _prepare_template_vars(data: dict) -> dict: # Protein targets w = weight[0]['weight'] if weight else prof.get('height',178) - 100 + w = float(w) # Convert Decimal to float for math operations vars['protein_ziel_low'] = round(w * 1.6) vars['protein_ziel_high'] = round(w * 2.2) # Activity summary if activity: n = len(activity) - total_kcal = sum(a.get('kcal_active',0) for a in 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" -- 2.43.0 From 47a268f42602cc5b52ce86e2ceff94d9e5273a35 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 21:52:57 +0100 Subject: [PATCH 23/34] fix: comprehensive PostgreSQL Decimal handling across all endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed all remaining Decimal → float conversion issues found by audit. **Fixed Endpoints:** 1. Weight Stats: min/max/avg calculations 2. Activity Stats: kcal/duration accumulation 3. Nutrition Weekly: average calculations 4. Template Variables: all f-string Decimal formatting 5. CSV Export: all numeric value formatting 6. JSON Export: added Decimal handler 7. ZIP Export: added Decimal handler 8. Correlations: weight, nutrition, caliper values **Changes:** - Added `from decimal import Decimal` import - Weight stats: convert to float for min/max/avg - Activity: float() in sum() and accumulation - Nutrition: float() in averages - Template vars: float() for weight_aktuell, kf_aktuell, goals - CSV: float() in all f-strings (weight, circ, caliper, nutrition, activity) - JSON/ZIP: custom decimal_handler for json.dumps() - Correlations: float() for all numeric DB values Prevents: - TypeError in math operations - "Decimal('X')" strings in exports - JSON serialization failures All numeric values from PostgreSQL now properly converted to float. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 65 ++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/backend/main.py b/backend/main.py index 1417b6d..39e5ecc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ 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 @@ -268,9 +269,9 @@ def weight_stats(x_profile_id: Optional[str]=Header(default=None), session: dict 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 ──────────────────────────────────────────────────────────── @@ -428,13 +429,14 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di "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") @@ -590,11 +592,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 @@ -613,7 +617,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 @@ -768,10 +772,10 @@ def _prepare_template_vars(data: dict) -> dict: "name": prof.get('name', 'Nutzer'), "geschlecht": "männlich" if prof.get('sex') == 'm' else "weiblich", "height": prof.get('height', 178), - "goal_weight": prof.get('goal_weight') or "nicht gesetzt", - "goal_bf_pct": prof.get('goal_bf_pct') or "nicht gesetzt", - "weight_aktuell": weight[0]['weight'] if weight else "keine Daten", - "kf_aktuell": caliper[0]['body_fat_pct'] if caliper and caliper[0].get('body_fat_pct') else "unbekannt", + "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 @@ -798,7 +802,8 @@ def _prepare_template_vars(data: dict) -> dict: # Caliper summary if caliper: c = caliper[0] - vars['caliper_summary'] = f"KF: {c.get('body_fat_pct','?')}%, Methode: {c.get('sf_method','?')}" + 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" @@ -807,7 +812,7 @@ def _prepare_template_vars(data: dict) -> dict: c = circ[0] parts = [] for k in ['c_waist', 'c_belly', 'c_hip']: - if c.get(k): parts.append(f"{k.split('_')[1]}: {c[k]}cm") + 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" @@ -1431,28 +1436,28 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D 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"{r['weight']}kg", r['note'] or ""]) + writer.writerow(["Gewicht", r['date'], f"{float(r['weight'])}kg", r['note'] or ""]) # Circumferences cur.execute("SELECT date, c_waist, c_belly, c_hip FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) for r in cur.fetchall(): - details = f"Taille:{r['c_waist']}cm Bauch:{r['c_belly']}cm Hüfte:{r['c_hip']}cm" + 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"{r['body_fat_pct']}%", f"Magermasse:{r['lean_mass']}kg"]) + 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"{r['kcal']}kcal", f"Protein:{r['protein_g']}g"]) + 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"{r['duration_min']}min {r['kcal_active']}kcal"]) + writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) output.seek(0) return StreamingResponse( @@ -1500,7 +1505,12 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) data['insights'] = [r2d(r) for r in cur.fetchall()] - json_str = json.dumps(data, indent=2, default=str) + 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", @@ -1549,7 +1559,12 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) data['insights'] = [r2d(r) for r in cur.fetchall()] - zf.writestr("data.json", json.dumps(data, indent=2, default=str)) + def decimal_handler(obj): + if isinstance(obj, Decimal): + return float(obj) + return str(obj) + + zf.writestr("data.json", json.dumps(data, indent=2, default=decimal_handler)) # Add photos if they exist with get_db() as conn: -- 2.43.0 From 0797a8f55cb3de00f446171492132c3ce6b6584c Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 22:00:35 +0100 Subject: [PATCH 24/34] fix: export endpoints now include auth headers Changed from window.open() to fetch() + Blob download. window.open() cannot send custom headers, causing 401 errors. **Changed:** - exportZip: fetch with auth, download blob as .zip - exportJson: fetch with auth, download blob as .json - exportCsv: fetch with auth, download blob as .csv All exports now work with authenticated sessions. Co-Authored-By: Claude Opus 4.6 --- frontend/src/utils/api.js | 42 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 003f9e4..5b6792b 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -88,9 +88,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'), -- 2.43.0 From a0660e7a409f2cbc45cbb33b4195c360793b5fc2 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 22:04:42 +0100 Subject: [PATCH 25/34] fix: use api.exportZip/Json instead of window.open SettingsPage was still calling window.open() directly, bypassing the auth-enabled fetch methods in api.js. Changed buttons to use api.exportZip() and api.exportJson() which properly include authentication headers. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/SettingsPage.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 27e5b23..2c9ba3f 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -291,12 +291,12 @@ export default function SettingsPage() { )} {canExport && <> -- 2.43.0 From 64d1b9bf7bf43079b0eff10f07a3e8efe149ec83 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 22:15:31 +0100 Subject: [PATCH 26/34] feat: implement comprehensive ZIP export per v9c specification Complete rewrite of ZIP export to match CLAUDE.md specification. **Structure:** - README.txt (format explanation) - profile.json (no password hash, includes stats) - data/*.csv (5 separate CSV files) - insights/ai_insights.json - photos/*.jpg **CSV Format:** - Delimiter: semicolon (;) - Encoding: UTF-8 with BOM (Excel compatible) - Date format: YYYY-MM-DD - Decimal separator: dot (.) - NULL values: empty string - First row: header **Files:** - data/weight.csv (id, date, weight, note, source, created) - data/circumferences.csv (8 measurement points) - data/caliper.csv (skinfold measurements + bf%) - data/nutrition.csv (kcal, protein, fat, carbs) - data/activity.csv (type, duration, kcal, HR, distance) **Filename:** mitai-export-{name}-{YYYY-MM-DD}.zip Ready for import functionality (v9c). Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 195 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 162 insertions(+), 33 deletions(-) diff --git a/backend/main.py b/backend/main.py index 39e5ecc..ecccfd0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1519,67 +1519,196 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= @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 (JSON + photos).""" + """Export all data as ZIP (CSV + JSON + photos) per specification.""" pid = get_pid(x_profile_id) - # Check export permission + # Check export permission & get profile 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']: + 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") - # Create ZIP in memory + # 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: - # Add JSON 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()) + # 1. README.txt + readme = f"""Mitai Jinkendo – Datenexport +Version: 2 +Exportiert am: {export_date} +Profil: {profile_name} - cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) - data['weight'] = [r2d(r) for r in cur.fetchall()] +Inhalt: +- profile.json: Profildaten und Einstellungen +- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8) +- insights/: KI-Auswertungen (JSON) +- photos/: Progress-Fotos (JPEG) - cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) - data['circumferences'] = [r2d(r) for r in cur.fetchall()] +Import: +Dieser Export kann in Mitai Jinkendo unter +Einstellungen → Import → "Mitai Backup importieren" +wieder eingespielt werden. - cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) - data['caliper'] = [r2d(r) for r in cur.fetchall()] +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')) - cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) - data['nutrition'] = [r2d(r) for r in cur.fetchall()] + # 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'] - cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) - data['activity'] = [r2d(r) for r in cur.fetchall()] + 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": int(prof['dob'][:4]) 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')) - cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) - data['insights'] = [r2d(r) for r in cur.fetchall()] + # 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']) - def decimal_handler(obj): - if isinstance(obj, Decimal): - return float(obj) - return str(obj) + # 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']) - zf.writestr("data.json", json.dumps(data, indent=2, default=decimal_handler)) + # 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']) - # Add photos if they exist - with get_db() as conn: - cur = get_cursor(conn) + # 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(): - zf.write(photo_path, f"photos/{photo['date'] or i}_{photo_path.name}") + 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=mitai-export-{pid}.zip"} + headers={"Content-Disposition": f"attachment; filename={filename}"} ) -- 2.43.0 From b6f8b11685c87b1c14418ff053525d958f717dec Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 22:41:51 +0100 Subject: [PATCH 27/34] fix: handle datetime.date object for birth_year in ZIP export PostgreSQL returns dob as datetime.date object, not string. Changed from prof['dob'][:4] to prof['dob'].year Error was: TypeError: 'datetime.date' object is not subscriptable Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index ecccfd0..fd54af7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1599,7 +1599,7 @@ Datumsformat: YYYY-MM-DD "email": prof.get('email'), "sex": prof.get('sex'), "height": float(prof['height']) if prof.get('height') else None, - "birth_year": int(prof['dob'][:4]) if prof.get('dob') 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'), -- 2.43.0 From e10e9d7eb9d0ba7c9884de25bd290c12863c94f3 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 22:48:40 +0100 Subject: [PATCH 28/34] fix: photos now display in History with token auth Problem: Photo endpoint requires auth header, but can't send headers. Solution: - Backend: Added require_auth_flexible() that accepts token via header OR query param - Backend: Photo endpoint uses flexible auth - Frontend: photoUrl() now appends ?token=xxx to URL Photos in History/Verlauf now display correctly. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 16 +++++++++++++--- frontend/src/utils/api.js | 5 ++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index fd54af7..d446dfa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -149,6 +149,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") @@ -500,13 +506,17 @@ async def upload_photo(file: UploadFile=File(...), date: str="", return {"id":fid,"date":date} @app.get("/api/photos/{fid}") -def get_photo(fid: str, session: dict=Depends(require_auth)): +def get_photo(fid: str, session: dict=Depends(require_auth_flexible)): + """Get photo by ID. Auth via header or query param (for tags).""" with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT path FROM photos WHERE id=%s", (fid,)) row = cur.fetchone() - if not row: raise HTTPException(404) - return FileResponse(row['path']) + if not row: raise HTTPException(404, "Photo not found") + photo_path = Path(PHOTOS_DIR) / row['path'] + if not photo_path.exists(): + raise HTTPException(404, "Photo file not found") + return FileResponse(photo_path) @app.get("/api/photos") def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5b6792b..a0634a1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -68,7 +68,10 @@ export const api = { return fetch(`${BASE}/photos`,{method:'POST',body:fd,headers:hdrs()}).then(r=>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)=>{ -- 2.43.0 From 115d97533584a849f54398747bd3e838104c8620 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 22:52:35 +0100 Subject: [PATCH 29/34] feat: add ZIP import functionality - Backend: POST /api/import/zip endpoint with validation and rollback - CSV import with ON CONFLICT DO NOTHING for duplicate detection - Photo import with existence check - AI insights import - Frontend: file upload UI in SettingsPage - Import summary showing count per category - Full transaction rollback on error Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 145 ++++++++++++++++ backend/main.py | 245 ++++++++++++++++++++++++++++ frontend/src/pages/SettingsPage.jsx | 118 +++++++++++++- 3 files changed, 507 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 56a15dd..5442bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -502,3 +502,148 @@ count = cur.fetchone()['count'] # Dict key - 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/main.py b/backend/main.py index d446dfa..f10acff 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1722,3 +1722,248 @@ Datumsformat: YYYY-MM-DD 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/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 2c9ba3f..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 = {} @@ -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 && (
Date: Thu, 19 Mar 2026 06:28:48 +0100 Subject: [PATCH 30/34] =?UTF-8?q?fix:=20admins=20k=C3=B6nnen=20jetzt=20all?= =?UTF-8?q?e=20Prompts=20sehen=20und=20bearbeiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/prompts checkt nun ob User admin ist - Admins sehen ALLE Prompts (inkl. pipeline_ und inaktive) - Normale User sehen nur aktive Einzelanalysen (wie bisher) - Frontend (Analysis.jsx) zeigt Pipeline-Prompts bereits korrekt: * Gruppiert nach "Einzelanalysen" und "Mehrstufige Pipeline" * JSON-Prompts (Stage 1) mit oranger Border und Badge * Warnung über JSON-Format bereits vorhanden - CSS-Variablen --warn, --warn-bg, --warn-text bereits definiert Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index f10acff..c30b46b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1063,10 +1063,22 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses @app.get("/api/prompts") def list_prompts(session: dict=Depends(require_auth)): - """List all available AI prompts.""" + """ + 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) - cur.execute("SELECT * FROM ai_prompts WHERE active=true AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order") + 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}") -- 2.43.0 From 4886f00826b967662790016912734d4f62de9861 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 19 Mar 2026 06:36:37 +0100 Subject: [PATCH 31/34] =?UTF-8?q?fix:=20Auth-Token=20f=C3=BCr=20Prompt-Bea?= =?UTF-8?q?rbeitung=20fehlte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KRITISCHER BUG behoben: - savePrompt() und Aktivieren/Deaktivieren sendeten KEIN Auth-Token - Backend require_admin() warf deshalb 401 Unauthorized - Prompt-Bearbeitung funktionierte überhaupt nicht (auch für Admins) Fix: - X-Auth-Token Header zu beiden fetch()-Calls hinzugefügt - Token aus localStorage wie in anderen Admin-Funktionen Rechtesystem BESTÄTIGT korrekt: ✅ Backend: nur require_admin() darf Prompts ändern ✅ DB: ai_prompts hat KEINE profile_id → universell ✅ Frontend: Tab "Prompts" nur für isAdmin sichtbar Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/Analysis.jsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Analysis.jsx b/frontend/src/pages/Analysis.jsx index 3838f06..ca9e734 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() } @@ -356,9 +359,14 @@ export default function Analysis() { {p.description &&
{p.description}
}
@@ -393,13 +393,15 @@ export default function Analysis() { 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}
}
-- 2.43.0 From 961f905279bd3512e90a92b4fabef848e1337513 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 19 Mar 2026 07:47:59 +0100 Subject: [PATCH 33/34] fix: drei kritische Bugs in KI-Analysen behoben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROBLEM 1: Deaktivierte Prompts auf Verlaufsseiten sichtbar - Deaktivierte Analysen (z.B. "Komposition") wurden auf Verlaufsseiten (Körper, Ernährung, etc.) als klickbare Buttons angezeigt FIX: - Prompts werden jetzt in History.jsx geladen (api.listPrompts) - filterActiveSlugs() filtert nur aktive Prompts - InsightBox zeigt nur Buttons für aktive Analysen PROBLEM 2: Pipeline konnte nicht deaktiviert werden - Mehrstufige Gesamtanalyse war immer sichtbar FIX: - Pipeline ist nur verfügbar wenn ALLE Sub-Prompts aktiv sind - Prüft: pipeline_body, pipeline_nutrition, pipeline_activity, pipeline_synthesis, pipeline_goals - Deaktiviere einen Sub-Prompt → Pipeline verschwindet PROBLEM 3: Fehler "z.text is not a function" - Nach Analyse-Ausführung auf Verlaufsseiten kam Fehler - Code behandelte api.runInsight() wie fetch()-Response FIX: - api.runInsight() gibt bereits JSON zurück, nicht Response - Entfernte fehlerhafte if(!r.ok) und await r.text() - Error-Handling wie in Analysis.jsx (catch e.message) DATEIEN: - frontend/src/pages/History.jsx: alle 3 Fixes - frontend/src/pages/Analysis.jsx: Pipeline-Verfügbarkeit Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/Analysis.jsx | 60 ++++++++++++++++++--------------- frontend/src/pages/History.jsx | 39 ++++++++++++--------- 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/frontend/src/pages/Analysis.jsx b/frontend/src/pages/Analysis.jsx index 27377be..4c55d33 100644 --- a/frontend/src/pages/Analysis.jsx +++ b/frontend/src/pages/Analysis.jsx @@ -179,6 +179,10 @@ export default function Analysis() { const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_')) + // Pipeline is available only if ALL pipeline sub-prompts are active + const pipelineSlugs = ['pipeline_body','pipeline_nutrition','pipeline_activity','pipeline_synthesis','pipeline_goals'] + const pipelineAvailable = pipelineSlugs.every(slug => prompts.find(p=>p.slug===slug)?.active) + return (

KI-Analyse

@@ -221,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 && (
)} -
) } // ── 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 (
-- 2.43.0 From c40b30737ac4c894e58a27c480975260f7fae1de Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 19 Mar 2026 07:56:36 +0100 Subject: [PATCH 34/34] =?UTF-8?q?feat:=20zentraler=20Schalter=20f=C3=BCr?= =?UTF-8?q?=20Pipeline-Deaktivierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VORHER: - Pipeline-Deaktivierung war nicht sichtbar im UI - Deaktivierung sollte über Sub-Prompts erfolgen (nicht intuitiv) JETZT: - Zentraler Toggle-Button direkt unter "Mehrstufige Pipeline" - Button-Text: "Gesamte Pipeline aktivieren/deaktivieren" - Visuelles Feedback: Warning-Box wird rot wenn deaktiviert IMPLEMENTIERUNG: Backend (main.py): - Neuer "pipeline" Master-Prompt wird automatisch angelegt - startup_event() ruft init_db() auf - Prompt: slug='pipeline', sort_order=-10 (ganz oben) - Template: 'PIPELINE_MASTER' (nur Steuerung, kein echtes Template) Frontend (Analysis.jsx): - Toggle-Button unter Sektionsüberschrift - Prüft: prompts.find(p=>p.slug==='pipeline')?.active - pipelineAvailable basiert auf diesem Prompt (nicht Sub-Prompts) - Warning-Box wechselt Farbe + Text: * Aktiv: Orange + JSON-Hinweis * Inaktiv: Rot + "Pipeline deaktiviert" VERHALTEN: ✅ Button im Prompts-Tab unter "Mehrstufige Pipeline" ✅ Klar sichtbar: "Gesamte Pipeline deaktivieren" ✅ Pipeline verschwindet von Analyse-Seite wenn deaktiviert ✅ Sub-Prompts bleiben unabhängig editierbar Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 25 ++++++++++++++- frontend/src/pages/Analysis.jsx | 54 ++++++++++++++++++++++++++------- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/backend/main.py b/backend/main.py index 76339a0..730665f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -40,11 +40,34 @@ app.add_middleware( 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 - pass + + # Ensure "pipeline" master prompt exists + with get_db() as conn: + 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: diff --git a/frontend/src/pages/Analysis.jsx b/frontend/src/pages/Analysis.jsx index 4c55d33..630bc1b 100644 --- a/frontend/src/pages/Analysis.jsx +++ b/frontend/src/pages/Analysis.jsx @@ -179,9 +179,9 @@ export default function Analysis() { const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_')) - // Pipeline is available only if ALL pipeline sub-prompts are active - const pipelineSlugs = ['pipeline_body','pipeline_nutrition','pipeline_activity','pipeline_synthesis','pipeline_goals'] - const pipelineAvailable = pipelineSlugs.every(slug => prompts.find(p=>p.slug===slug)?.active) + // 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 (
@@ -386,15 +386,47 @@ 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 ( -- 2.43.0