Compare commits
No commits in common. "9d15336144cc886f77fb95c4c1558df61b4da735" and "4a6dc378541e5f5b3681beefee8a790e0fa8a281" have entirely different histories.
9d15336144
...
4a6dc37854
12
.env.example
12
.env.example
|
|
@ -1,9 +1,9 @@
|
||||||
# ── Datenbank (PostgreSQL v9b+) ────────────────────────────────
|
# ── Datenbank ──────────────────────────────────────────────────
|
||||||
DB_HOST=postgres
|
# v9 (PostgreSQL):
|
||||||
DB_PORT=5432
|
DB_PASSWORD=sicheres_passwort_hier
|
||||||
DB_NAME=mitai
|
|
||||||
DB_USER=mitai
|
# v8 (SQLite, legacy):
|
||||||
DB_PASSWORD=mitaiDB-PostgreSQL-Neckar-strong
|
# DATA_DIR=/app/data
|
||||||
|
|
||||||
# ── KI ─────────────────────────────────────────────────────────
|
# ── KI ─────────────────────────────────────────────────────────
|
||||||
# OpenRouter (empfohlen):
|
# OpenRouter (empfohlen):
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -58,6 +58,3 @@ coverage/
|
||||||
# Temp
|
# Temp
|
||||||
tmp/
|
tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
#.claude Konfiguration
|
|
||||||
.claude/
|
|
||||||
631
CLAUDE.md
631
CLAUDE.md
|
|
@ -1,32 +1,33 @@
|
||||||
# Mitai Jinkendo – Entwickler-Kontext für Claude Code
|
# Mitai Jinkendo – Entwickler-Kontext für Claude Code
|
||||||
|
|
||||||
## Projekt-Übersicht
|
## Projekt-Übersicht
|
||||||
**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).
|
**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).
|
||||||
|
|
||||||
**Produktfamilie:** mitai · miken · ikigai · shinkan · kenkou (alle unter jinkendo.de)
|
**Produktfamilie:** body · fight · guard · train · mind (alle unter jinkendo.de)
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech-Stack
|
||||||
| Komponente | Technologie | Version |
|
| Komponente | Technologie | Version |
|
||||||
|-----------|-------------|---------|
|
|-----------|-------------|---------|
|
||||||
| Frontend | React 18 + Vite + PWA | Node 20 |
|
| Frontend | React 18 + Vite + PWA | Node 20 |
|
||||||
| Backend | FastAPI (Python) | Python 3.12 |
|
| Backend | FastAPI (Python) | Python 3.12 |
|
||||||
| Datenbank | PostgreSQL 16 (Alpine) | v9b |
|
| Datenbank | PostgreSQL | 16 (Ziel: v9) / SQLite (aktuell: v8) |
|
||||||
| Container | Docker + Docker Compose | - |
|
| Container | Docker + Docker Compose | - |
|
||||||
| Webserver | nginx (Reverse Proxy) | Alpine |
|
| Webserver | nginx (Reverse Proxy + HTTPS) | Alpine |
|
||||||
| Auth | Token-basiert + bcrypt | - |
|
| Auth | Token-basiert (eigene Impl.) | - |
|
||||||
| KI | OpenRouter API (claude-sonnet-4) | - |
|
| KI | OpenRouter API (claude-sonnet-4) | - |
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
| Service | Prod | Dev |
|
| Service | Intern | Extern (Dev) |
|
||||||
|---------|------|-----|
|
|---------|--------|-------------|
|
||||||
| Frontend | 3002 | 3099 |
|
| Frontend | 80 (nginx) | 3002 |
|
||||||
| Backend | 8002 | 8099 |
|
| Backend | 8000 (uvicorn) | 8002 |
|
||||||
|
| PostgreSQL | 5432 | nicht exponiert |
|
||||||
|
|
||||||
## Verzeichnisstruktur
|
## Verzeichnisstruktur
|
||||||
```
|
```
|
||||||
mitai-jinkendo/
|
mitai-jinkendo/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── main.py # FastAPI App, alle Endpoints (~2000 Zeilen)
|
│ ├── main.py # FastAPI App, alle Endpoints
|
||||||
│ ├── requirements.txt
|
│ ├── requirements.txt
|
||||||
│ └── Dockerfile
|
│ └── Dockerfile
|
||||||
├── frontend/
|
├── frontend/
|
||||||
|
|
@ -37,159 +38,113 @@ mitai-jinkendo/
|
||||||
│ │ │ ├── AuthContext.jsx # Session, Login, Logout
|
│ │ │ ├── AuthContext.jsx # Session, Login, Logout
|
||||||
│ │ │ └── ProfileContext.jsx # Aktives Profil
|
│ │ │ └── ProfileContext.jsx # Aktives Profil
|
||||||
│ │ ├── pages/ # Alle Screens
|
│ │ ├── pages/ # Alle Screens
|
||||||
│ │ └── utils/
|
│ │ ├── utils/
|
||||||
│ │ ├── api.js # Alle API-Calls (injiziert Token automatisch)
|
│ │ │ ├── api.js # Alle API-Calls (injiziert Token + ProfileId)
|
||||||
│ │ ├── calc.js # Körperfett-Formeln
|
│ │ │ ├── calc.js # Körperfett-Formeln
|
||||||
│ │ ├── interpret.js # Regelbasierte Auswertung
|
│ │ │ ├── interpret.js # Regelbasierte Auswertung
|
||||||
│ │ ├── Markdown.jsx # Eigener MD-Renderer
|
│ │ │ ├── Markdown.jsx # Eigener MD-Renderer
|
||||||
│ │ └── guideData.js # Messanleitungen
|
│ │ │ └── guideData.js # Messanleitungen
|
||||||
│ └── public/ # Icons (Jinkendo Ensō-Logo)
|
│ │ └── main.jsx
|
||||||
├── .gitea/workflows/
|
│ ├── public/ # Icons (Jinkendo Ensō-Logo)
|
||||||
│ ├── deploy-prod.yml # Auto-Deploy bei Push auf main
|
│ ├── index.html
|
||||||
│ ├── deploy-dev.yml # Auto-Deploy bei Push auf develop
|
│ ├── vite.config.js
|
||||||
│ └── test.yml # Build-Test bei jedem Push
|
│ └── Dockerfile
|
||||||
├── docker-compose.yml # Produktion (Ports 3002/8002)
|
├── nginx/
|
||||||
├── docker-compose.dev-env.yml # Development (Ports 3099/8099)
|
│ └── nginx.conf
|
||||||
|
├── docker-compose.yml # Produktion
|
||||||
|
├── docker-compose.dev.yml # Entwicklung (Hot-Reload)
|
||||||
|
├── .env.example
|
||||||
└── CLAUDE.md # Diese Datei
|
└── CLAUDE.md # Diese Datei
|
||||||
```
|
```
|
||||||
|
|
||||||
## Aktuelle Version: v9b
|
## Aktuelle Version: v8
|
||||||
|
|
||||||
### Was implementiert ist:
|
### Was implementiert ist:
|
||||||
- ✅ Multi-User mit E-Mail + Passwort Login (bcrypt)
|
- ✅ Multi-User mit PIN/Passwort-Auth + Token-Sessions
|
||||||
- ✅ 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
|
- ✅ Admin/User Rollen, KI-Limits, Export-Berechtigungen
|
||||||
- ✅ Gewicht, Umfänge, Caliper (4 Formeln), Ernährung, Aktivität
|
- ✅ Gewicht, Umfänge, Caliper (4 Formeln), Ernährung, Aktivität
|
||||||
- ✅ FDDB CSV-Import (Ernährung), Apple Health CSV-Import (Aktivität)
|
- ✅ FDDB CSV-Import (Ernährung), Apple Health CSV-Import (Aktivität)
|
||||||
- ✅ KI-Analyse: 6 Einzel-Prompts + 3-stufige Pipeline (parallel)
|
- ✅ KI-Analyse: 6 Einzel-Prompts + 3-stufige Pipeline (parallel)
|
||||||
- ✅ Konfigurierbare Prompts mit Template-Variablen (Admin kann bearbeiten)
|
- ✅ Konfigurierbare Prompts mit Template-Variablen
|
||||||
- ✅ Verlauf mit 5 Tabs + Zeitraumfilter + KI pro Sektion
|
- ✅ Verlauf mit 5 Tabs + Zeitraumfilter + KI pro Sektion
|
||||||
- ✅ Dashboard mit Kennzahlen, Zielfortschritt, Combo-Chart
|
- ✅ Dashboard mit Kennzahlen, Zielfortschritt, Combo-Chart
|
||||||
- ✅ Assistent-Modus (Schritt-für-Schritt Messung)
|
- ✅ Assistent-Modus (Schritt-für-Schritt Messung)
|
||||||
- ✅ PWA (iPhone Home Screen), Jinkendo Ensō-Logo
|
- ✅ PWA (iPhone Home Screen), Jinkendo-Icon
|
||||||
- ✅ E-Mail (SMTP) für Password-Recovery
|
- ✅ E-Mail (SMTP) für Password-Recovery
|
||||||
- ✅ Admin-Panel: User verwalten, KI-Limits, E-Mail-Test, PIN/Email setzen
|
- ✅ 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
|
|
||||||
- ✅ PostgreSQL 16 Migration (vollständig von SQLite migriert)
|
|
||||||
- ✅ Export: CSV, JSON, ZIP (mit Fotos)
|
|
||||||
- ✅ Automatische SQLite→PostgreSQL Migration bei Container-Start
|
|
||||||
|
|
||||||
### Was in v9c kommt:
|
### Was in v9 kommt:
|
||||||
|
- 🔲 PostgreSQL Migration (aktuell: SQLite)
|
||||||
|
- 🔲 Auth-Middleware auf ALLE Endpoints
|
||||||
|
- 🔲 bcrypt statt SHA256
|
||||||
|
- 🔲 Rate Limiting
|
||||||
|
- 🔲 CORS auf Domain beschränken
|
||||||
- 🔲 Selbst-Registrierung mit E-Mail-Bestätigung
|
- 🔲 Selbst-Registrierung mit E-Mail-Bestätigung
|
||||||
- 🔲 Freemium Tier-System (free/basic/premium/selfhosted)
|
- 🔲 Freemium Tier-System (free/basic/premium/selfhosted)
|
||||||
- 🔲 14-Tage Trial automatisch
|
- 🔲 Login via E-Mail statt Profil-Liste
|
||||||
- 🔲 Einladungslinks für Beta-Nutzer
|
- 🔲 nginx + Let's Encrypt
|
||||||
- 🔲 Admin kann Tiers manuell setzen
|
|
||||||
|
|
||||||
### Was in v9d kommt:
|
## Datenbank-Schema (SQLite, v8)
|
||||||
- 🔲 OAuth2-Grundgerüst für Fitness-Connectoren
|
|
||||||
- 🔲 Strava Connector
|
|
||||||
- 🔲 Withings Connector (Waage)
|
|
||||||
- 🔲 Garmin Connector
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Infrastruktur
|
|
||||||
```
|
|
||||||
Internet → privat.stommer.com (Fritz!Box DynDNS)
|
|
||||||
→ Synology NAS (Reverse Proxy + Let's Encrypt)
|
|
||||||
→ Raspberry Pi 5 (192.168.2.49, Docker)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Git Workflow
|
|
||||||
```
|
|
||||||
develop branch → Auto-Deploy → dev.mitai.jinkendo.de (Port 3099/8099)
|
|
||||||
main branch → Auto-Deploy → mitai.jinkendo.de (Port 3002/8002)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deployment-Befehle (manuell falls nötig)
|
|
||||||
```bash
|
|
||||||
# Prod
|
|
||||||
cd /home/lars/docker/bodytrack
|
|
||||||
docker compose -f docker-compose.yml build --no-cache
|
|
||||||
docker compose -f docker-compose.yml up -d
|
|
||||||
|
|
||||||
# Dev
|
|
||||||
cd /home/lars/docker/bodytrack-dev
|
|
||||||
docker compose -f docker-compose.dev-env.yml build --no-cache
|
|
||||||
docker compose -f docker-compose.dev-env.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Datenbank-Schema (PostgreSQL 16, v9b)
|
|
||||||
### Wichtige Tabellen:
|
### Wichtige Tabellen:
|
||||||
- `profiles` – Nutzer (role, pin_hash/bcrypt, email, auth_type, ai_enabled, tier)
|
- `profiles` – Nutzer mit Auth (role, pin_hash, auth_type, ai_enabled, export_enabled)
|
||||||
- `sessions` – Auth-Tokens mit Ablaufdatum
|
- `sessions` – Auth-Tokens mit Ablaufdatum
|
||||||
- `weight_log` – Gewichtseinträge (profile_id, date, weight)
|
- `weight_log` – Gewichtseinträge (profile_id, date, weight)
|
||||||
- `circumference_log` – 8 Umfangspunkte
|
- `circumference_log` – 8 Umfangspunkte
|
||||||
- `caliper_log` – Hautfaltenmessung, 4 Methoden
|
- `caliper_log` – Hautfaltenmessung, 4 Methoden
|
||||||
- `nutrition_log` – Kalorien + Makros (aus FDDB-CSV)
|
- `nutrition_log` – Kalorien + Makros (aus FDDB-CSV)
|
||||||
- `activity_log` – Training (aus Apple Health oder manuell)
|
- `activity_log` – Training (aus Apple Health oder manuell)
|
||||||
- `photos` – Progress Photos
|
|
||||||
- `ai_insights` – KI-Auswertungen (scope = prompt-slug)
|
- `ai_insights` – KI-Auswertungen (scope = prompt-slug)
|
||||||
- `ai_prompts` – Konfigurierbare Prompts mit Templates (11 Prompts)
|
- `ai_prompts` – Konfigurierbare Prompts mit Templates
|
||||||
- `ai_usage` – KI-Calls pro Tag pro Profil
|
- `ai_usage` – KI-Calls pro Tag pro Profil
|
||||||
|
|
||||||
**Schema-Datei:** `backend/schema.sql` (vollständiges PostgreSQL-Schema)
|
## Auth-Flow (aktuell v8)
|
||||||
**Migration-Script:** `backend/migrate_to_postgres.py` (SQLite→PostgreSQL, automatisch)
|
|
||||||
|
|
||||||
## Auth-Flow (v9b)
|
|
||||||
```
|
```
|
||||||
Login-Screen → E-Mail + Passwort → Token im localStorage
|
Login-Screen → Profil-Liste → PIN/Passwort → Token im localStorage
|
||||||
Token → X-Auth-Token Header → Backend require_auth()
|
Token → X-Auth-Token Header → Backend require_auth()
|
||||||
Profile-Id → aus Session (nicht aus Header!)
|
Profile-Id → aus Session (nicht aus Header!)
|
||||||
SHA256 Passwörter → automatisch zu bcrypt migriert beim Login
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API-Konventionen
|
## API-Konventionen
|
||||||
- Alle Endpoints: `/api/...`
|
- Alle Endpoints: `/api/...`
|
||||||
- Auth-Header: `X-Auth-Token: <token>`
|
- Auth-Header: `X-Auth-Token: <token>`
|
||||||
|
- Profile-Header: `X-Profile-Id: <uuid>` (nur wo noch nicht migriert)
|
||||||
- Responses: immer JSON
|
- Responses: immer JSON
|
||||||
- Fehler: `{"detail": "Fehlermeldung"}`
|
- Fehler: `{"detail": "Fehlermeldung"}`
|
||||||
- Rate Limit überschritten: HTTP 429
|
|
||||||
|
|
||||||
## Umgebungsvariablen (.env)
|
## Umgebungsvariablen (.env)
|
||||||
```
|
```
|
||||||
# Database (PostgreSQL)
|
OPENROUTER_API_KEY= # KI-Calls
|
||||||
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
|
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||||
ANTHROPIC_API_KEY= # Direkte Anthropic API (optional)
|
ANTHROPIC_API_KEY= # Alternative zu OpenRouter
|
||||||
|
SMTP_HOST= # E-Mail
|
||||||
# Email
|
|
||||||
SMTP_HOST= # E-Mail (für Recovery)
|
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASS=
|
SMTP_PASS=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
APP_URL= # Für Links in E-Mails
|
||||||
# App
|
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
|
PHOTOS_DIR=/app/photos
|
||||||
ENVIRONMENT=production
|
# 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
|
## Wichtige Hinweise für Claude Code
|
||||||
1. **Ports immer 3002/8002 (Prod) oder 3099/8099 (Dev)** – nie ändern
|
1. **Ports immer 3002/8002** – nie ändern
|
||||||
2. **npm install** (nicht npm ci) – kein package-lock.json vorhanden
|
2. **npm install** (nicht npm ci) – kein package-lock.json vorhanden
|
||||||
3. **PostgreSQL-Migrations** – Schema-Änderungen in `backend/schema.sql`, dann Container neu bauen
|
3. **SQLite safe_alters** – neue Spalten immer via _safe_alters() hinzufügen
|
||||||
4. **Pipeline-Prompts** haben slug-Prefix `pipeline_` – nie als Einzelanalyse zeigen
|
4. **Pipeline-Prompts** haben slug-Prefix `pipeline_` – nie als Einzelanalyse zeigen
|
||||||
5. **dayjs.week()** braucht Plugin – stattdessen native JS ISO-Wochenberechnung
|
5. **dayjs.week()** braucht Plugin – stattdessen native JS Wochenberechnung
|
||||||
6. **useNavigate()** nur in React-Komponenten, nicht in Helper-Functions
|
6. **useNavigate()** nur in React-Komponenten (Großbuchstabe), nicht in Helper-Functions
|
||||||
7. **api.js nutzen** für alle API-Calls – injiziert Token automatisch
|
7. **Bar fill=function** in Recharts nicht unterstützt – nur statische Farben
|
||||||
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
|
## Code-Style
|
||||||
- React: Functional Components, Hooks
|
- React: Functional Components, Hooks
|
||||||
|
|
@ -197,453 +152,3 @@ ENVIRONMENT=production
|
||||||
- API-Calls: immer über `api.js` (injiziert Token automatisch)
|
- API-Calls: immer über `api.js` (injiziert Token automatisch)
|
||||||
- Kein TypeScript (bewusst, für Einfachheit)
|
- Kein TypeScript (bewusst, für Einfachheit)
|
||||||
- Python: keine Type-Hints Pflicht, aber bei neuen Funktionen erwünscht
|
- 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
|
|
||||||
<div style={{display:'flex',alignItems:'center',
|
|
||||||
justifyContent:'space-between',marginBottom:20}}>
|
|
||||||
<div style={{fontSize:20,fontWeight:700,color:'var(--text1)'}}>
|
|
||||||
Seitentitel
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-primary">Aktion</button>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ladezustand:**
|
|
||||||
```jsx
|
|
||||||
if (loading) return (
|
|
||||||
<div style={{display:'flex',justifyContent:'center',padding:40}}>
|
|
||||||
<div className="spinner"/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fehlerzustand:**
|
|
||||||
```jsx
|
|
||||||
if (error) return (
|
|
||||||
<div style={{color:'var(--danger)',padding:16,textAlign:'center'}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Leerer Zustand:**
|
|
||||||
```jsx
|
|
||||||
{items.length === 0 && (
|
|
||||||
<div style={{textAlign:'center',padding:40,color:'var(--text3)'}}>
|
|
||||||
<div style={{fontSize:32,marginBottom:8}}>📭</div>
|
|
||||||
<div>Noch keine Einträge</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Metric Card:**
|
|
||||||
```jsx
|
|
||||||
<div className="card" style={{padding:16,textAlign:'center'}}>
|
|
||||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:4}}>LABEL</div>
|
|
||||||
<div style={{fontSize:24,fontWeight:700,color:'var(--accent)'}}>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize:12,color:'var(--text3)'}}>Einheit</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Jinkendo Logo-System
|
|
||||||
```
|
|
||||||
Grundelement: Ensō-Kreis (offen, Lücke 4-5 Uhr)
|
|
||||||
Farbe Ensō: #1D9E75
|
|
||||||
Hintergrund: #085041 (dunkelgrün)
|
|
||||||
Kern-Symbol: #5DCAA5 (mintgrün)
|
|
||||||
Wortmarke: Jin(light) + ken(bold #1D9E75) + do(light)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verfügbare Custom Commands
|
|
||||||
```
|
|
||||||
/deploy → Commit + Push vorbereiten
|
|
||||||
/merge-to-prod → develop → main mergen
|
|
||||||
/test → Manuelle Tests durchführen
|
|
||||||
/new-feature → Neues Feature-Template
|
|
||||||
/ui-component → Neue Komponente erstellen
|
|
||||||
/ui-page → Neue Seite erstellen
|
|
||||||
/fix-bug → Bug analysieren und beheben
|
|
||||||
/add-endpoint → Neuen API-Endpoint hinzufügen
|
|
||||||
/db-add-column → Neue DB-Spalte hinzufügen
|
|
||||||
```
|
|
||||||
|
|
||||||
## Jinkendo App-Familie & Markenarchitektur
|
|
||||||
|
|
||||||
### Philosophie
|
|
||||||
**Jinkendo** (人拳道) = Jin (人 Mensch) + Ken (拳 Faust) + Do (道 Weg)
|
|
||||||
"Der menschliche Weg der Kampfkunst" – ruhig aber kraftvoll, Selbstwahrnehmung, Meditation, Zielorientiert
|
|
||||||
|
|
||||||
### App-Familie (Subdomain-Architektur)
|
|
||||||
```
|
|
||||||
mitai.jinkendo.de → Körper-Tracker (身体 = eigener Körper) ← DIESE APP
|
|
||||||
miken.jinkendo.de → Meditation (眉間 = drittes Auge)
|
|
||||||
ikigai.jinkendo.de → Lebenssinn/Ziele (生き甲斐)
|
|
||||||
shinkan.jinkendo.de → Kampfsport (真観 = wahre Wahrnehmung)
|
|
||||||
kenkou.jinkendo.de → Gesundheit allgemein (健康) – für später aufsparen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Registrierte Domains
|
|
||||||
- jinkendo.de, jinkendo.com, jinkendo.life – alle registriert bei Strato
|
|
||||||
|
|
||||||
## v9b Detailplan – Freemium Tier-System
|
|
||||||
|
|
||||||
### Tier-Modell
|
|
||||||
```
|
|
||||||
free → Selbst-Registrierung, 14-Tage Trial, eingeschränkt
|
|
||||||
basic → Kernfunktionen (Abo Stufe 1)
|
|
||||||
premium → Alles inkl. KI und Connectoren (Abo Stufe 2)
|
|
||||||
selfhosted → Lars' Heimversion, keine Einschränkungen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Geplante DB-Erweiterungen (profiles Tabelle)
|
|
||||||
```sql
|
|
||||||
tier TEXT DEFAULT 'free'
|
|
||||||
trial_ends_at TEXT -- ISO datetime
|
|
||||||
sub_valid_until TEXT -- ISO datetime
|
|
||||||
email_verified INTEGER DEFAULT 0
|
|
||||||
email_verify_token TEXT
|
|
||||||
invited_by TEXT -- profile_id FK
|
|
||||||
invitation_token TEXT
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tier-Limits (geplant)
|
|
||||||
| Feature | free | basic | premium | selfhosted |
|
|
||||||
|---------|------|-------|---------|------------|
|
|
||||||
| Gewicht-Einträge | 30 | unbegrenzt | unbegrenzt | unbegrenzt |
|
|
||||||
| KI-Analysen/Monat | 0 | 3 | unbegrenzt | unbegrenzt |
|
|
||||||
| Ernährung Import | ❌ | ✅ | ✅ | ✅ |
|
|
||||||
| Export | ❌ | ✅ | ✅ | ✅ |
|
|
||||||
| Fitness-Connectoren | ❌ | ❌ | ✅ | ✅ |
|
|
||||||
|
|
||||||
### Registrierungs-Flow (geplant)
|
|
||||||
```
|
|
||||||
1. Selbst-Registrierung: Name + E-Mail + Passwort
|
|
||||||
2. Auto-Trial: tier='free', trial_ends_at=now+14d
|
|
||||||
3. E-Mail-Bestätigung → email_verified=1
|
|
||||||
4. Trial läuft ab → Upgrade-Prompt
|
|
||||||
5. Einladungslinks: Admin generiert Token → direkt basic-Tier
|
|
||||||
6. Stripe Integration: später (v9b ohne Stripe, nur Tier-Logik)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Infrastruktur Details
|
|
||||||
|
|
||||||
### Heimnetzwerk
|
|
||||||
```
|
|
||||||
Internet
|
|
||||||
→ Fritz!Box 7530 AX (DynDNS: privat.stommer.com)
|
|
||||||
→ Synology NAS (192.168.2.63, Reverse Proxy + Let's Encrypt)
|
|
||||||
→ Raspberry Pi 5 (192.168.2.49, Docker)
|
|
||||||
→ MiniPC (192.168.2.144, Gitea auf Port 3000)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Synology Reverse Proxy Regeln
|
|
||||||
```
|
|
||||||
mitai.jinkendo.de → HTTP 192.168.2.49:3002 (Prod Frontend)
|
|
||||||
dev.mitai.jinkendo.de → HTTP 192.168.2.49:3099 (Dev Frontend)
|
|
||||||
```
|
|
||||||
|
|
||||||
### AdGuard DNS Rewrites (für internes Routing)
|
|
||||||
```
|
|
||||||
mitai.jinkendo.de → 192.168.2.63
|
|
||||||
dev.mitai.jinkendo.de → 192.168.2.63
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fritz!Box DNS-Rebind Ausnahmen
|
|
||||||
```
|
|
||||||
jinkendo.de
|
|
||||||
mitai.jinkendo.de
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pi Verzeichnisstruktur
|
|
||||||
```
|
|
||||||
/home/lars/docker/
|
|
||||||
├── bodytrack/ → Prod (main branch, docker-compose.yml)
|
|
||||||
└── bodytrack-dev/ → Dev (develop branch, docker-compose.dev-env.yml)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gitea Runner
|
|
||||||
```
|
|
||||||
Runner: raspberry-pi (auf Pi installiert)
|
|
||||||
Service: /etc/systemd/system/gitea-runner.service
|
|
||||||
Binary: /home/lars/gitea-runner/act_runner
|
|
||||||
```
|
|
||||||
|
|
||||||
### Container Namen
|
|
||||||
```
|
|
||||||
Prod: mitai-api, mitai-ui
|
|
||||||
Dev: dev-mitai-api, dev-mitai-ui
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bekannte Probleme & Lösungen
|
|
||||||
|
|
||||||
### dayjs.week() – NIEMALS verwenden
|
|
||||||
```javascript
|
|
||||||
// ❌ Falsch:
|
|
||||||
const week = dayjs(date).week()
|
|
||||||
|
|
||||||
// ✅ Richtig (ISO 8601):
|
|
||||||
const weekNum = (() => {
|
|
||||||
const dt = new Date(date)
|
|
||||||
dt.setHours(0,0,0,0)
|
|
||||||
dt.setDate(dt.getDate()+4-(dt.getDay()||7))
|
|
||||||
const y = new Date(dt.getFullYear(),0,1)
|
|
||||||
return Math.ceil(((dt-y)/86400000+1)/7)
|
|
||||||
})()
|
|
||||||
```
|
|
||||||
|
|
||||||
### session=Depends(require_auth) – Korrekte Platzierung
|
|
||||||
```python
|
|
||||||
# ❌ Falsch (führt zu NameError oder ungeschütztem Endpoint):
|
|
||||||
def endpoint(x_profile_id: Optional[str] = Header(default=None, session=Depends(require_auth))):
|
|
||||||
|
|
||||||
# ✅ Richtig (separater Parameter):
|
|
||||||
def endpoint(x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth)):
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recharts Bar fill=function – nicht unterstützt
|
|
||||||
```jsx
|
|
||||||
// ❌ Falsch:
|
|
||||||
<Bar fill={(entry) => entry.color}/>
|
|
||||||
|
|
||||||
// ✅ Richtig:
|
|
||||||
<Bar fill="#1D9E75"/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### PostgreSQL Boolean-Syntax
|
|
||||||
```python
|
|
||||||
# ❌ Falsch (SQLite-Syntax):
|
|
||||||
cur.execute("SELECT * FROM ai_prompts WHERE active=1")
|
|
||||||
|
|
||||||
# ✅ Richtig (PostgreSQL):
|
|
||||||
cur.execute("SELECT * FROM ai_prompts WHERE active=true")
|
|
||||||
```
|
|
||||||
|
|
||||||
### RealDictCursor für dict-like row access
|
|
||||||
```python
|
|
||||||
# ❌ Falsch:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT COUNT(*) FROM weight_log")
|
|
||||||
count = cur.fetchone()[0] # Tuple index
|
|
||||||
|
|
||||||
# ✅ Richtig:
|
|
||||||
cur = get_cursor(conn) # Returns RealDictCursor
|
|
||||||
cur.execute("SELECT COUNT(*) as count FROM weight_log")
|
|
||||||
count = cur.fetchone()['count'] # Dict key
|
|
||||||
```
|
|
||||||
|
|
||||||
## v9b Migration – Lessons Learned
|
|
||||||
|
|
||||||
### PostgreSQL Migration (SQLite → PostgreSQL)
|
|
||||||
**Problem:** Docker Build hing 30+ Minuten bei `apt-get install postgresql-client`
|
|
||||||
**Lösung:** Alle apt-get dependencies entfernt, reine Python-Lösung mit psycopg2-binary
|
|
||||||
|
|
||||||
**Problem:** Leere date-Strings (`''`) führten zu PostgreSQL-Fehlern
|
|
||||||
**Lösung:** Migration-Script konvertiert leere Strings zu NULL für DATE-Spalten
|
|
||||||
|
|
||||||
**Problem:** Boolean-Felder (SQLite INTEGER 0/1 vs PostgreSQL BOOLEAN)
|
|
||||||
**Lösung:** Migration konvertiert automatisch, Backend nutzt `active=true` statt `active=1`
|
|
||||||
|
|
||||||
### API Endpoint Consistency (März 2026)
|
|
||||||
**Problem:** 11 kritische Endpoint-Mismatches zwischen Frontend und Backend gefunden
|
|
||||||
**Gelöst:**
|
|
||||||
- AI-Endpoints konsistent: `/api/insights/run/{slug}`, `/api/insights/pipeline`
|
|
||||||
- Password-Reset: `/api/auth/forgot-password`, `/api/auth/reset-password`
|
|
||||||
- Admin-Endpoints: `/permissions`, `/email`, `/pin` Sub-Routes
|
|
||||||
- Export: JSON + ZIP Endpoints hinzugefügt
|
|
||||||
- Prompt-Bearbeitung: PUT-Endpoint für Admins
|
|
||||||
|
|
||||||
**Tool:** Vollständiger Audit via Explore-Agent empfohlen bei größeren Änderungen
|
|
||||||
|
|
||||||
## Export/Import Spezifikation (v9c)
|
|
||||||
|
|
||||||
### ZIP-Export Struktur
|
|
||||||
```
|
|
||||||
mitai-export-{name}-{YYYY-MM-DD}.zip
|
|
||||||
├── README.txt ← Erklärung des Formats + Versionsnummer
|
|
||||||
├── profile.json ← Profildaten (ohne Passwort-Hash)
|
|
||||||
├── data/
|
|
||||||
│ ├── weight.csv ← Gewichtsverlauf
|
|
||||||
│ ├── circumferences.csv ← Umfänge (8 Messpunkte)
|
|
||||||
│ ├── caliper.csv ← Caliper-Messungen
|
|
||||||
│ ├── nutrition.csv ← Ernährungsdaten
|
|
||||||
│ └── activity.csv ← Aktivitäten
|
|
||||||
├── insights/
|
|
||||||
│ └── ai_insights.json ← KI-Auswertungen (alle gespeicherten)
|
|
||||||
└── photos/
|
|
||||||
├── {date}_{id}.jpg ← Progress-Fotos
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### CSV Format (alle Dateien)
|
|
||||||
```
|
|
||||||
- Trennzeichen: Semikolon (;) – Excel/LibreOffice kompatibel
|
|
||||||
- Encoding: UTF-8 mit BOM (für Windows Excel)
|
|
||||||
- Datumsformat: YYYY-MM-DD
|
|
||||||
- Dezimaltrennzeichen: Punkt (.)
|
|
||||||
- Erste Zeile: Header
|
|
||||||
- Nullwerte: leer (nicht "null" oder "NULL")
|
|
||||||
```
|
|
||||||
|
|
||||||
### weight.csv Spalten
|
|
||||||
```
|
|
||||||
id;date;weight;note;source;created
|
|
||||||
```
|
|
||||||
|
|
||||||
### circumferences.csv Spalten
|
|
||||||
```
|
|
||||||
id;date;waist;hip;chest;neck;upper_arm;thigh;calf;forearm;note;created
|
|
||||||
```
|
|
||||||
|
|
||||||
### caliper.csv Spalten
|
|
||||||
```
|
|
||||||
id;date;chest;abdomen;thigh;tricep;subscapular;suprailiac;midaxillary;method;bf_percent;note;created
|
|
||||||
```
|
|
||||||
|
|
||||||
### nutrition.csv Spalten
|
|
||||||
```
|
|
||||||
id;date;meal_name;kcal;protein;fat;carbs;fiber;note;source;created
|
|
||||||
```
|
|
||||||
|
|
||||||
### activity.csv Spalten
|
|
||||||
```
|
|
||||||
id;date;name;type;duration_min;kcal;heart_rate_avg;heart_rate_max;distance_km;note;source;created
|
|
||||||
```
|
|
||||||
|
|
||||||
### profile.json Struktur
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"export_version": "2",
|
|
||||||
"export_date": "2026-03-18",
|
|
||||||
"app": "Mitai Jinkendo",
|
|
||||||
"profile": {
|
|
||||||
"name": "Lars",
|
|
||||||
"email": "lars@stommer.com",
|
|
||||||
"sex": "m",
|
|
||||||
"height": 178,
|
|
||||||
"birth_year": 1980,
|
|
||||||
"goal_weight": 82,
|
|
||||||
"goal_bf_pct": 14,
|
|
||||||
"avatar_color": "#1D9E75",
|
|
||||||
"auth_type": "password",
|
|
||||||
"session_days": 30,
|
|
||||||
"ai_enabled": true,
|
|
||||||
"tier": "selfhosted"
|
|
||||||
},
|
|
||||||
"stats": {
|
|
||||||
"weight_entries": 150,
|
|
||||||
"nutrition_entries": 300,
|
|
||||||
"activity_entries": 45,
|
|
||||||
"photos": 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ai_insights.json Struktur
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "uuid",
|
|
||||||
"scope": "gesamt",
|
|
||||||
"created": "2026-03-18T10:00:00",
|
|
||||||
"result": "KI-Analyse Text..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### README.txt Inhalt
|
|
||||||
```
|
|
||||||
Mitai Jinkendo – Datenexport
|
|
||||||
Version: 2
|
|
||||||
Exportiert am: YYYY-MM-DD
|
|
||||||
Profil: {name}
|
|
||||||
|
|
||||||
Inhalt:
|
|
||||||
- profile.json: Profildaten und Einstellungen
|
|
||||||
- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8)
|
|
||||||
- insights/: KI-Auswertungen (JSON)
|
|
||||||
- photos/: Progress-Fotos (JPEG)
|
|
||||||
|
|
||||||
Import:
|
|
||||||
Dieser Export kann in Mitai Jinkendo unter
|
|
||||||
Einstellungen → Import → "Mitai Backup importieren"
|
|
||||||
wieder eingespielt werden.
|
|
||||||
|
|
||||||
Format-Version 2 (ab v9b):
|
|
||||||
Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
|
|
||||||
Trennzeichen: Semikolon (;)
|
|
||||||
Datumsformat: YYYY-MM-DD
|
|
||||||
```
|
|
||||||
|
|
||||||
### Import-Funktion (zu implementieren)
|
|
||||||
**Endpoint:** `POST /api/import/zip`
|
|
||||||
**Verhalten:**
|
|
||||||
- Akzeptiert ZIP-Datei (multipart/form-data)
|
|
||||||
- Erkennt export_version aus profile.json
|
|
||||||
- Importiert nur fehlende Einträge (kein Duplikat)
|
|
||||||
- Fotos werden nicht überschrieben falls bereits vorhanden
|
|
||||||
- Gibt Zusammenfassung zurück: wie viele Einträge je Kategorie importiert
|
|
||||||
- Bei Fehler: vollständiger Rollback (alle oder nichts)
|
|
||||||
|
|
||||||
**Duplikat-Erkennung:**
|
|
||||||
```python
|
|
||||||
# INSERT ... ON CONFLICT (profile_id, date) DO NOTHING
|
|
||||||
# weight: UNIQUE (profile_id, date)
|
|
||||||
# nutrition: UNIQUE (profile_id, date, meal_name)
|
|
||||||
# activity: UNIQUE (profile_id, date, name)
|
|
||||||
# caliper: UNIQUE (profile_id, date)
|
|
||||||
# circumferences: UNIQUE (profile_id, date)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend:** Neuer Button in SettingsPage:
|
|
||||||
```
|
|
||||||
[ZIP exportieren] [JSON exportieren] [Backup importieren]
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,7 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
# No system packages needed - we use Python (psycopg2-binary) for PostgreSQL checks
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create directories
|
|
||||||
RUN mkdir -p /app/data /app/photos
|
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"]
|
|
||||||
|
|
|
||||||
150
backend/db.py
150
backend/db.py
|
|
@ -1,150 +0,0 @@
|
||||||
"""
|
|
||||||
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)
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Database initialization script for PostgreSQL.
|
|
||||||
Replaces psql commands in startup.sh with pure Python.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2 import OperationalError
|
|
||||||
|
|
||||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
|
||||||
DB_PORT = os.getenv("DB_PORT", "5432")
|
|
||||||
DB_NAME = os.getenv("DB_NAME", "mitai_dev")
|
|
||||||
DB_USER = os.getenv("DB_USER", "mitai_dev")
|
|
||||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
|
|
||||||
|
|
||||||
def get_connection():
|
|
||||||
"""Get PostgreSQL connection."""
|
|
||||||
return psycopg2.connect(
|
|
||||||
host=DB_HOST,
|
|
||||||
port=DB_PORT,
|
|
||||||
database=DB_NAME,
|
|
||||||
user=DB_USER,
|
|
||||||
password=DB_PASSWORD
|
|
||||||
)
|
|
||||||
|
|
||||||
def wait_for_postgres(max_retries=30):
|
|
||||||
"""Wait for PostgreSQL to be ready."""
|
|
||||||
print("\nChecking PostgreSQL connection...")
|
|
||||||
for i in range(1, max_retries + 1):
|
|
||||||
try:
|
|
||||||
conn = get_connection()
|
|
||||||
conn.close()
|
|
||||||
print("✓ PostgreSQL ready")
|
|
||||||
return True
|
|
||||||
except OperationalError:
|
|
||||||
print(f" Waiting for PostgreSQL... (attempt {i}/{max_retries})")
|
|
||||||
time.sleep(2)
|
|
||||||
|
|
||||||
print(f"✗ PostgreSQL not ready after {max_retries} attempts")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def check_table_exists(table_name="profiles"):
|
|
||||||
"""Check if a table exists."""
|
|
||||||
try:
|
|
||||||
conn = get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM information_schema.tables
|
|
||||||
WHERE table_schema='public' AND table_name=%s
|
|
||||||
""", (table_name,))
|
|
||||||
count = cur.fetchone()[0]
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return count > 0
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error checking table: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def load_schema(schema_file="/app/schema.sql"):
|
|
||||||
"""Load schema from SQL file."""
|
|
||||||
try:
|
|
||||||
with open(schema_file, 'r') as f:
|
|
||||||
schema_sql = f.read()
|
|
||||||
|
|
||||||
conn = get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(schema_sql)
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
print("✓ Schema loaded from schema.sql")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Error loading schema: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_profile_count():
|
|
||||||
"""Get number of profiles in database."""
|
|
||||||
try:
|
|
||||||
conn = get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT COUNT(*) FROM profiles")
|
|
||||||
count = cur.fetchone()[0]
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return count
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting profile count: {e}")
|
|
||||||
return -1
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("═══════════════════════════════════════════════════════════")
|
|
||||||
print("MITAI JINKENDO - Database Initialization (v9b)")
|
|
||||||
print("═══════════════════════════════════════════════════════════")
|
|
||||||
|
|
||||||
# Wait for PostgreSQL
|
|
||||||
if not wait_for_postgres():
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Check schema
|
|
||||||
print("\nChecking database schema...")
|
|
||||||
if not check_table_exists("profiles"):
|
|
||||||
print(" Schema not found, initializing...")
|
|
||||||
if not load_schema():
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print("✓ Schema already exists")
|
|
||||||
|
|
||||||
# Check for migration
|
|
||||||
print("\nChecking for SQLite data migration...")
|
|
||||||
sqlite_db = "/app/data/bodytrack.db"
|
|
||||||
profile_count = get_profile_count()
|
|
||||||
|
|
||||||
if os.path.exists(sqlite_db) and profile_count == 0:
|
|
||||||
print(" SQLite database found and PostgreSQL is empty")
|
|
||||||
print(" Starting automatic migration...")
|
|
||||||
# Import and run migration
|
|
||||||
try:
|
|
||||||
from migrate_to_postgres import main as migrate
|
|
||||||
migrate()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Migration failed: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
elif os.path.exists(sqlite_db) and profile_count > 0:
|
|
||||||
print(f"⚠ SQLite DB exists but PostgreSQL already has {profile_count} profiles")
|
|
||||||
print(" Skipping migration (already migrated)")
|
|
||||||
elif not os.path.exists(sqlite_db):
|
|
||||||
print("✓ No SQLite database found (fresh install or already migrated)")
|
|
||||||
else:
|
|
||||||
print("✓ No migration needed")
|
|
||||||
|
|
||||||
print("\n✓ Database initialization complete")
|
|
||||||
3032
backend/main.py
3032
backend/main.py
File diff suppressed because it is too large
Load Diff
|
|
@ -1,373 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
SQLite → PostgreSQL Migration Script für Mitai Jinkendo (v9a → v9b)
|
|
||||||
|
|
||||||
Migrates all data from SQLite to PostgreSQL with type conversions and validation.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Inside Docker container:
|
|
||||||
python migrate_to_postgres.py
|
|
||||||
|
|
||||||
# Or locally with custom paths:
|
|
||||||
DATA_DIR=./data DB_HOST=localhost python migrate_to_postgres.py
|
|
||||||
|
|
||||||
Environment Variables:
|
|
||||||
SQLite Source:
|
|
||||||
DATA_DIR (default: ./data)
|
|
||||||
|
|
||||||
PostgreSQL Target:
|
|
||||||
DB_HOST (default: postgres)
|
|
||||||
DB_PORT (default: 5432)
|
|
||||||
DB_NAME (default: mitai)
|
|
||||||
DB_USER (default: mitai)
|
|
||||||
DB_PASSWORD (required)
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any, List, Optional
|
|
||||||
import psycopg2
|
|
||||||
from psycopg2.extras import execute_values, RealDictCursor
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# CONFIGURATION
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
# SQLite Source
|
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
|
||||||
SQLITE_DB = DATA_DIR / "bodytrack.db"
|
|
||||||
|
|
||||||
# PostgreSQL Target
|
|
||||||
PG_CONFIG = {
|
|
||||||
'host': os.getenv("DB_HOST", "postgres"),
|
|
||||||
'port': int(os.getenv("DB_PORT", "5432")),
|
|
||||||
'database': os.getenv("DB_NAME", "mitai"),
|
|
||||||
'user': os.getenv("DB_USER", "mitai"),
|
|
||||||
'password': os.getenv("DB_PASSWORD", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Tables to migrate (in order - respects foreign keys)
|
|
||||||
TABLES = [
|
|
||||||
'profiles',
|
|
||||||
'sessions',
|
|
||||||
'ai_usage',
|
|
||||||
'ai_prompts',
|
|
||||||
'weight_log',
|
|
||||||
'circumference_log',
|
|
||||||
'caliper_log',
|
|
||||||
'nutrition_log',
|
|
||||||
'activity_log',
|
|
||||||
'photos',
|
|
||||||
'ai_insights',
|
|
||||||
]
|
|
||||||
|
|
||||||
# Columns that need INTEGER (0/1) → BOOLEAN conversion
|
|
||||||
BOOLEAN_COLUMNS = {
|
|
||||||
'profiles': ['ai_enabled', 'export_enabled'],
|
|
||||||
'ai_prompts': ['active'],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# CONVERSION HELPERS
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def convert_value(value: Any, column: str, table: str) -> Any:
|
|
||||||
"""
|
|
||||||
Convert SQLite value to PostgreSQL-compatible format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: Raw value from SQLite
|
|
||||||
column: Column name
|
|
||||||
table: Table name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Converted value suitable for PostgreSQL
|
|
||||||
"""
|
|
||||||
# NULL values pass through
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Empty string → NULL for DATE columns (PostgreSQL doesn't accept '' for DATE type)
|
|
||||||
if isinstance(value, str) and value.strip() == '' and column == 'date':
|
|
||||||
return None
|
|
||||||
|
|
||||||
# INTEGER → BOOLEAN conversion
|
|
||||||
if table in BOOLEAN_COLUMNS and column in BOOLEAN_COLUMNS[table]:
|
|
||||||
return bool(value)
|
|
||||||
|
|
||||||
# All other values pass through
|
|
||||||
# (PostgreSQL handles TEXT timestamps, UUIDs, and numerics automatically)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def convert_row(row: Dict[str, Any], table: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Convert entire row from SQLite to PostgreSQL format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
row: Dictionary with column:value pairs from SQLite
|
|
||||||
table: Table name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Converted dictionary
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
column: convert_value(value, column, table)
|
|
||||||
for column, value in row.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# MIGRATION LOGIC
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def get_sqlite_rows(table: str) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Fetch all rows from SQLite table.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
table: Table name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of dictionaries (one per row)
|
|
||||||
"""
|
|
||||||
conn = sqlite3.connect(SQLITE_DB)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = cur.execute(f"SELECT * FROM {table}").fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
except sqlite3.OperationalError as e:
|
|
||||||
# Table doesn't exist in SQLite (OK, might be new in v9b)
|
|
||||||
print(f" ⚠ Table '{table}' not found in SQLite: {e}")
|
|
||||||
return []
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_table(pg_conn, table: str) -> Dict[str, int]:
|
|
||||||
"""
|
|
||||||
Migrate one table from SQLite to PostgreSQL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pg_conn: PostgreSQL connection
|
|
||||||
table: Table name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with stats: {'sqlite_count': N, 'postgres_count': M}
|
|
||||||
"""
|
|
||||||
print(f" Migrating '{table}'...", end=' ', flush=True)
|
|
||||||
|
|
||||||
# Fetch from SQLite
|
|
||||||
sqlite_rows = get_sqlite_rows(table)
|
|
||||||
sqlite_count = len(sqlite_rows)
|
|
||||||
|
|
||||||
if sqlite_count == 0:
|
|
||||||
print("(empty)")
|
|
||||||
return {'sqlite_count': 0, 'postgres_count': 0}
|
|
||||||
|
|
||||||
# Convert rows
|
|
||||||
converted_rows = [convert_row(row, table) for row in sqlite_rows]
|
|
||||||
|
|
||||||
# Get column names
|
|
||||||
columns = list(converted_rows[0].keys())
|
|
||||||
cols_str = ', '.join(columns)
|
|
||||||
placeholders = ', '.join(['%s'] * len(columns))
|
|
||||||
|
|
||||||
# Insert into PostgreSQL
|
|
||||||
pg_cur = pg_conn.cursor()
|
|
||||||
|
|
||||||
# Build INSERT query
|
|
||||||
query = f"INSERT INTO {table} ({cols_str}) VALUES %s"
|
|
||||||
|
|
||||||
# Prepare values (list of tuples)
|
|
||||||
values = [
|
|
||||||
tuple(row[col] for col in columns)
|
|
||||||
for row in converted_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
# Batch insert with execute_values (faster than executemany)
|
|
||||||
try:
|
|
||||||
execute_values(pg_cur, query, values, page_size=100)
|
|
||||||
except psycopg2.Error as e:
|
|
||||||
print(f"\n ✗ Insert failed: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Verify row count
|
|
||||||
pg_cur.execute(f"SELECT COUNT(*) FROM {table}")
|
|
||||||
postgres_count = pg_cur.fetchone()[0]
|
|
||||||
|
|
||||||
print(f"✓ {sqlite_count} rows → {postgres_count} rows")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'sqlite_count': sqlite_count,
|
|
||||||
'postgres_count': postgres_count
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def verify_migration(pg_conn, stats: Dict[str, Dict[str, int]]):
|
|
||||||
"""
|
|
||||||
Verify migration integrity.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pg_conn: PostgreSQL connection
|
|
||||||
stats: Migration stats per table
|
|
||||||
"""
|
|
||||||
print("\n═══════════════════════════════════════════════════════════")
|
|
||||||
print("VERIFICATION")
|
|
||||||
print("═══════════════════════════════════════════════════════════")
|
|
||||||
|
|
||||||
all_ok = True
|
|
||||||
|
|
||||||
for table, counts in stats.items():
|
|
||||||
sqlite_count = counts['sqlite_count']
|
|
||||||
postgres_count = counts['postgres_count']
|
|
||||||
|
|
||||||
status = "✓" if sqlite_count == postgres_count else "✗"
|
|
||||||
print(f" {status} {table:20s} SQLite: {sqlite_count:5d} → PostgreSQL: {postgres_count:5d}")
|
|
||||||
|
|
||||||
if sqlite_count != postgres_count:
|
|
||||||
all_ok = False
|
|
||||||
|
|
||||||
# Sample some data
|
|
||||||
print("\n───────────────────────────────────────────────────────────")
|
|
||||||
print("SAMPLE DATA (first profile)")
|
|
||||||
print("───────────────────────────────────────────────────────────")
|
|
||||||
|
|
||||||
cur = pg_conn.cursor(cursor_factory=RealDictCursor)
|
|
||||||
cur.execute("SELECT * FROM profiles LIMIT 1")
|
|
||||||
profile = cur.fetchone()
|
|
||||||
|
|
||||||
if profile:
|
|
||||||
for key, value in dict(profile).items():
|
|
||||||
print(f" {key:20s} = {value}")
|
|
||||||
else:
|
|
||||||
print(" (no profiles found)")
|
|
||||||
|
|
||||||
print("\n───────────────────────────────────────────────────────────")
|
|
||||||
print("SAMPLE DATA (latest weight entry)")
|
|
||||||
print("───────────────────────────────────────────────────────────")
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM weight_log ORDER BY date DESC LIMIT 1")
|
|
||||||
weight = cur.fetchone()
|
|
||||||
|
|
||||||
if weight:
|
|
||||||
for key, value in dict(weight).items():
|
|
||||||
print(f" {key:20s} = {value}")
|
|
||||||
else:
|
|
||||||
print(" (no weight entries found)")
|
|
||||||
|
|
||||||
print("\n═══════════════════════════════════════════════════════════")
|
|
||||||
|
|
||||||
if all_ok:
|
|
||||||
print("✓ MIGRATION SUCCESSFUL - All row counts match!")
|
|
||||||
else:
|
|
||||||
print("✗ MIGRATION FAILED - Row count mismatch detected!")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
|
||||||
# MAIN
|
|
||||||
# ================================================================
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("═══════════════════════════════════════════════════════════")
|
|
||||||
print("MITAI JINKENDO - SQLite → PostgreSQL Migration (v9a → v9b)")
|
|
||||||
print("═══════════════════════════════════════════════════════════\n")
|
|
||||||
|
|
||||||
# Check SQLite DB exists
|
|
||||||
if not SQLITE_DB.exists():
|
|
||||||
print(f"✗ SQLite database not found: {SQLITE_DB}")
|
|
||||||
print(f" Set DATA_DIR environment variable if needed.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"✓ SQLite source: {SQLITE_DB}")
|
|
||||||
print(f"✓ PostgreSQL target: {PG_CONFIG['user']}@{PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['database']}\n")
|
|
||||||
|
|
||||||
# Check PostgreSQL password
|
|
||||||
if not PG_CONFIG['password']:
|
|
||||||
print("✗ DB_PASSWORD environment variable not set!")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Connect to PostgreSQL
|
|
||||||
print("Connecting to PostgreSQL...", end=' ', flush=True)
|
|
||||||
try:
|
|
||||||
pg_conn = psycopg2.connect(**PG_CONFIG)
|
|
||||||
print("✓")
|
|
||||||
except psycopg2.Error as e:
|
|
||||||
print(f"\n✗ Connection failed: {e}")
|
|
||||||
print("\nTroubleshooting:")
|
|
||||||
print(" - Is PostgreSQL running? (docker compose ps)")
|
|
||||||
print(" - Is DB_PASSWORD correct?")
|
|
||||||
print(" - Is the schema initialized? (schema.sql loaded?)")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Check if schema is initialized
|
|
||||||
print("Checking PostgreSQL schema...", end=' ', flush=True)
|
|
||||||
cur = pg_conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*) FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'profiles'
|
|
||||||
""")
|
|
||||||
if cur.fetchone()[0] == 0:
|
|
||||||
print("\n✗ Schema not initialized!")
|
|
||||||
print("\nRun this first:")
|
|
||||||
print(" docker compose exec backend python -c \"from main import init_db; init_db()\"")
|
|
||||||
print(" Or manually load schema.sql")
|
|
||||||
sys.exit(1)
|
|
||||||
print("✓")
|
|
||||||
|
|
||||||
# Check if PostgreSQL is empty
|
|
||||||
print("Checking if PostgreSQL is empty...", end=' ', flush=True)
|
|
||||||
cur.execute("SELECT COUNT(*) FROM profiles")
|
|
||||||
existing_profiles = cur.fetchone()[0]
|
|
||||||
if existing_profiles > 0:
|
|
||||||
print(f"\n⚠ WARNING: PostgreSQL already has {existing_profiles} profiles!")
|
|
||||||
response = input(" Continue anyway? This will create duplicates! (yes/no): ")
|
|
||||||
if response.lower() != 'yes':
|
|
||||||
print("Migration cancelled.")
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
print("✓")
|
|
||||||
|
|
||||||
print("\n───────────────────────────────────────────────────────────")
|
|
||||||
print("MIGRATION")
|
|
||||||
print("───────────────────────────────────────────────────────────")
|
|
||||||
|
|
||||||
stats = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
for table in TABLES:
|
|
||||||
stats[table] = migrate_table(pg_conn, table)
|
|
||||||
|
|
||||||
# Commit all changes
|
|
||||||
pg_conn.commit()
|
|
||||||
print("\n✓ All changes committed to PostgreSQL")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n✗ Migration failed: {e}")
|
|
||||||
print("Rolling back...")
|
|
||||||
pg_conn.rollback()
|
|
||||||
pg_conn.close()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Verification
|
|
||||||
verify_migration(pg_conn, stats)
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
pg_conn.close()
|
|
||||||
|
|
||||||
print("\n✓ Migration complete!")
|
|
||||||
print("\nNext steps:")
|
|
||||||
print(" 1. Test login with existing credentials")
|
|
||||||
print(" 2. Check Dashboard (weight chart, stats)")
|
|
||||||
print(" 3. Verify KI-Analysen work")
|
|
||||||
print(" 4. If everything works: commit + push to develop")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
@ -7,4 +7,3 @@ aiofiles==23.2.1
|
||||||
pydantic==2.7.1
|
pydantic==2.7.1
|
||||||
bcrypt==4.1.3
|
bcrypt==4.1.3
|
||||||
slowapi==0.1.9
|
slowapi==0.1.9
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
|
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
-- ================================================================
|
|
||||||
-- MITAI JINKENDO v9b – PostgreSQL Schema
|
|
||||||
-- ================================================================
|
|
||||||
-- Migration from SQLite to PostgreSQL
|
|
||||||
-- Includes v9b Tier System features
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
-- Enable UUID Extension
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- CORE TABLES
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
-- ── Profiles Table ──────────────────────────────────────────────
|
|
||||||
-- User/Profile management with auth and permissions
|
|
||||||
CREATE TABLE IF NOT EXISTS profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name VARCHAR(255) NOT NULL DEFAULT 'Nutzer',
|
|
||||||
avatar_color VARCHAR(7) DEFAULT '#1D9E75',
|
|
||||||
photo_id UUID,
|
|
||||||
sex VARCHAR(1) DEFAULT 'm' CHECK (sex IN ('m', 'w', 'd')),
|
|
||||||
dob DATE,
|
|
||||||
height NUMERIC(5,2) DEFAULT 178,
|
|
||||||
goal_weight NUMERIC(5,2),
|
|
||||||
goal_bf_pct NUMERIC(4,2),
|
|
||||||
|
|
||||||
-- Auth & Permissions
|
|
||||||
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
|
||||||
pin_hash TEXT,
|
|
||||||
auth_type VARCHAR(20) DEFAULT 'pin' CHECK (auth_type IN ('pin', 'email')),
|
|
||||||
session_days INTEGER DEFAULT 30,
|
|
||||||
ai_enabled BOOLEAN DEFAULT TRUE,
|
|
||||||
ai_limit_day INTEGER,
|
|
||||||
export_enabled BOOLEAN DEFAULT TRUE,
|
|
||||||
email VARCHAR(255) UNIQUE,
|
|
||||||
|
|
||||||
-- v9b: Tier System
|
|
||||||
tier VARCHAR(20) DEFAULT 'free' CHECK (tier IN ('free', 'basic', 'premium', 'selfhosted')),
|
|
||||||
tier_expires_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
trial_ends_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
invited_by UUID REFERENCES profiles(id),
|
|
||||||
|
|
||||||
-- Timestamps
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_email ON profiles(email) WHERE email IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_tier ON profiles(tier);
|
|
||||||
|
|
||||||
-- ── Sessions Table ──────────────────────────────────────────────
|
|
||||||
-- Auth token management
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
token VARCHAR(64) PRIMARY KEY,
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_profile_id ON sessions(profile_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
|
||||||
|
|
||||||
-- ── AI Usage Tracking ───────────────────────────────────────────
|
|
||||||
-- Daily AI call limits per profile
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_usage (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
call_count INTEGER DEFAULT 0,
|
|
||||||
UNIQUE(profile_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_profile_date ON ai_usage(profile_id, date);
|
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- TRACKING TABLES
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
-- ── Weight Log ──────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS weight_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
weight NUMERIC(5,2) NOT NULL,
|
|
||||||
note TEXT,
|
|
||||||
source VARCHAR(20) DEFAULT 'manual',
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_weight_log_profile_date ON weight_log(profile_id, date DESC);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_weight_log_profile_date_unique ON weight_log(profile_id, date);
|
|
||||||
|
|
||||||
-- ── Circumference Log ───────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS circumference_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
c_neck NUMERIC(5,2),
|
|
||||||
c_chest NUMERIC(5,2),
|
|
||||||
c_waist NUMERIC(5,2),
|
|
||||||
c_belly NUMERIC(5,2),
|
|
||||||
c_hip NUMERIC(5,2),
|
|
||||||
c_thigh NUMERIC(5,2),
|
|
||||||
c_calf NUMERIC(5,2),
|
|
||||||
c_arm NUMERIC(5,2),
|
|
||||||
notes TEXT,
|
|
||||||
photo_id UUID,
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_circumference_profile_date ON circumference_log(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- ── Caliper Log ─────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS caliper_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
sf_method VARCHAR(20) DEFAULT 'jackson3',
|
|
||||||
sf_chest NUMERIC(5,2),
|
|
||||||
sf_axilla NUMERIC(5,2),
|
|
||||||
sf_triceps NUMERIC(5,2),
|
|
||||||
sf_subscap NUMERIC(5,2),
|
|
||||||
sf_suprailiac NUMERIC(5,2),
|
|
||||||
sf_abdomen NUMERIC(5,2),
|
|
||||||
sf_thigh NUMERIC(5,2),
|
|
||||||
sf_calf_med NUMERIC(5,2),
|
|
||||||
sf_lowerback NUMERIC(5,2),
|
|
||||||
sf_biceps NUMERIC(5,2),
|
|
||||||
body_fat_pct NUMERIC(4,2),
|
|
||||||
lean_mass NUMERIC(5,2),
|
|
||||||
fat_mass NUMERIC(5,2),
|
|
||||||
notes TEXT,
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_caliper_profile_date ON caliper_log(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- ── Nutrition Log ───────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS nutrition_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
kcal NUMERIC(7,2),
|
|
||||||
protein_g NUMERIC(6,2),
|
|
||||||
fat_g NUMERIC(6,2),
|
|
||||||
carbs_g NUMERIC(6,2),
|
|
||||||
source VARCHAR(20) DEFAULT 'csv',
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_nutrition_profile_date ON nutrition_log(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- ── Activity Log ────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS activity_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
start_time TIME,
|
|
||||||
end_time TIME,
|
|
||||||
activity_type VARCHAR(50) NOT NULL,
|
|
||||||
duration_min NUMERIC(6,2),
|
|
||||||
kcal_active NUMERIC(7,2),
|
|
||||||
kcal_resting NUMERIC(7,2),
|
|
||||||
hr_avg NUMERIC(5,2),
|
|
||||||
hr_max NUMERIC(5,2),
|
|
||||||
distance_km NUMERIC(7,2),
|
|
||||||
rpe INTEGER CHECK (rpe >= 1 AND rpe <= 10),
|
|
||||||
source VARCHAR(20) DEFAULT 'manual',
|
|
||||||
notes TEXT,
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_profile_date ON activity_log(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- ── Photos ──────────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS photos (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
meas_id UUID, -- Legacy: reference to measurement (circumference/caliper)
|
|
||||||
date DATE,
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_photos_profile_date ON photos(profile_id, date DESC);
|
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- AI TABLES
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
-- ── AI Insights ─────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_insights (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
scope VARCHAR(50) NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ai_insights_profile_scope ON ai_insights(profile_id, scope, created DESC);
|
|
||||||
|
|
||||||
-- ── AI Prompts ──────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_prompts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
description TEXT,
|
|
||||||
template TEXT NOT NULL,
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_slug ON ai_prompts(slug);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_active_sort ON ai_prompts(active, sort_order);
|
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
-- Auto-update timestamp trigger for profiles
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_timestamp()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trigger_profiles_updated ON profiles;
|
|
||||||
CREATE TRIGGER trigger_profiles_updated
|
|
||||||
BEFORE UPDATE ON profiles
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_timestamp();
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trigger_ai_prompts_updated ON ai_prompts;
|
|
||||||
CREATE TRIGGER trigger_ai_prompts_updated
|
|
||||||
BEFORE UPDATE ON ai_prompts
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_updated_timestamp();
|
|
||||||
|
|
||||||
-- ================================================================
|
|
||||||
-- COMMENTS (Documentation)
|
|
||||||
-- ================================================================
|
|
||||||
|
|
||||||
COMMENT ON TABLE profiles IS 'User profiles with auth, permissions, and tier system';
|
|
||||||
COMMENT ON TABLE sessions IS 'Active auth tokens';
|
|
||||||
COMMENT ON TABLE ai_usage IS 'Daily AI call tracking per profile';
|
|
||||||
COMMENT ON TABLE weight_log IS 'Weight measurements';
|
|
||||||
COMMENT ON TABLE circumference_log IS 'Body circumference measurements (8 points)';
|
|
||||||
COMMENT ON TABLE caliper_log IS 'Skinfold measurements with body fat calculations';
|
|
||||||
COMMENT ON TABLE nutrition_log IS 'Daily nutrition intake (calories + macros)';
|
|
||||||
COMMENT ON TABLE activity_log IS 'Training sessions and activities';
|
|
||||||
COMMENT ON TABLE photos IS 'Progress photos';
|
|
||||||
COMMENT ON TABLE ai_insights IS 'AI-generated analysis results';
|
|
||||||
COMMENT ON TABLE ai_prompts IS 'Configurable AI prompt templates';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN profiles.tier IS 'Subscription tier: free, basic, premium, selfhosted';
|
|
||||||
COMMENT ON COLUMN profiles.trial_ends_at IS 'Trial expiration timestamp (14 days from registration)';
|
|
||||||
COMMENT ON COLUMN profiles.tier_expires_at IS 'Paid tier expiration timestamp';
|
|
||||||
COMMENT ON COLUMN profiles.invited_by IS 'Profile ID of inviter (for beta invitations)';
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Run database initialization with Python (no psql needed!)
|
|
||||||
python /app/db_init.py
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "✗ Database initialization failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Start Application ──────────────────────────────────────────
|
|
||||||
echo ""
|
|
||||||
echo "═══════════════════════════════════════════════════════════"
|
|
||||||
echo "Starting FastAPI application..."
|
|
||||||
echo "═══════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
exec uvicorn main:app --host 0.0.0.0 --port 8000
|
|
||||||
|
|
@ -1,56 +1,24 @@
|
||||||
services:
|
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:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: dev-mitai-api
|
container_name: dev-mitai-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8099:8000"
|
- "8099:8000"
|
||||||
depends_on:
|
|
||||||
postgres-dev:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
volumes:
|
||||||
- bodytrack_bodytrack-data:/app/data
|
- bodytrack_bodytrack-data:/app/data
|
||||||
- bodytrack_bodytrack-photos:/app/photos
|
- bodytrack_bodytrack-photos:/app/photos
|
||||||
environment:
|
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_API_KEY=${OPENROUTER_API_KEY}
|
||||||
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4}
|
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
|
||||||
# Email
|
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- SMTP_FROM=${SMTP_FROM}
|
- SMTP_FROM=${SMTP_FROM}
|
||||||
|
|
||||||
# App
|
|
||||||
- APP_URL=${APP_URL_DEV:-https://dev.mitai.jinkendo.de}
|
- APP_URL=${APP_URL_DEV:-https://dev.mitai.jinkendo.de}
|
||||||
|
- DATA_DIR=/app/data
|
||||||
- PHOTOS_DIR=/app/photos
|
- PHOTOS_DIR=/app/photos
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS_DEV:-*}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS_DEV:-*}
|
||||||
- ENVIRONMENT=development
|
- ENVIRONMENT=development
|
||||||
|
|
@ -65,7 +33,6 @@ services:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mitai_dev_postgres_data:
|
|
||||||
bodytrack_bodytrack-data:
|
bodytrack_bodytrack-data:
|
||||||
external: true
|
external: true
|
||||||
bodytrack_bodytrack-photos:
|
bodytrack_bodytrack-photos:
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,22 @@
|
||||||
services:
|
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:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: mitai-api
|
container_name: mitai-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8002:8000"
|
- "8002:8000"
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
volumes:
|
||||||
- bodytrack_bodytrack-data:/app/data
|
- bodytrack_bodytrack-data:/app/data
|
||||||
- bodytrack_bodytrack-photos:/app/photos
|
- bodytrack_bodytrack-photos:/app/photos
|
||||||
environment:
|
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_API_KEY=${OPENROUTER_API_KEY}
|
||||||
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4}
|
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-anthropic/claude-sonnet-4}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
|
||||||
# Email
|
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- SMTP_FROM=${SMTP_FROM}
|
- SMTP_FROM=${SMTP_FROM}
|
||||||
|
|
||||||
# App
|
|
||||||
- APP_URL=${APP_URL}
|
- APP_URL=${APP_URL}
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
- PHOTOS_DIR=/app/photos
|
- PHOTOS_DIR=/app/photos
|
||||||
|
|
@ -66,7 +33,6 @@ services:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mitai_postgres_data:
|
|
||||||
bodytrack_bodytrack-data:
|
bodytrack_bodytrack-data:
|
||||||
external: true
|
external: true
|
||||||
bodytrack_bodytrack-photos:
|
bodytrack_bodytrack-photos:
|
||||||
|
|
|
||||||
|
|
@ -150,11 +150,8 @@ export default function Analysis() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const savePrompt = async (promptId, data) => {
|
const savePrompt = async (promptId, data) => {
|
||||||
const token = localStorage.getItem('bodytrack_token')||''
|
|
||||||
await fetch(`/api/prompts/${promptId}`, {
|
await fetch(`/api/prompts/${promptId}`, {
|
||||||
method:'PUT',
|
method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data)
|
||||||
headers:{'Content-Type':'application/json', 'X-Auth-Token': token},
|
|
||||||
body:JSON.stringify(data)
|
|
||||||
})
|
})
|
||||||
setEditing(null); await loadAll()
|
setEditing(null); await loadAll()
|
||||||
}
|
}
|
||||||
|
|
@ -179,10 +176,6 @@ export default function Analysis() {
|
||||||
|
|
||||||
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_'))
|
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_'))
|
||||||
|
|
||||||
// Pipeline is available if the "pipeline" prompt is active
|
|
||||||
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
|
|
||||||
const pipelineAvailable = pipelinePrompt?.active ?? true // Default to true if not found (backwards compatibility)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">KI-Analyse</h1>
|
<h1 className="page-title">KI-Analyse</h1>
|
||||||
|
|
@ -225,8 +218,7 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pipeline button - only if all sub-prompts are active */}
|
{/* Pipeline button */}
|
||||||
{pipelineAvailable && (
|
|
||||||
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
||||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||||||
<div style={{flex:1}}>
|
<div style={{flex:1}}>
|
||||||
|
|
@ -256,7 +248,6 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!canUseAI && (
|
{!canUseAI && (
|
||||||
<div style={{padding:'14px 16px',background:'#FCEBEB',borderRadius:10,
|
<div style={{padding:'14px 16px',background:'#FCEBEB',borderRadius:10,
|
||||||
|
|
@ -354,26 +345,21 @@ export default function Analysis() {
|
||||||
Einzelanalysen
|
Einzelanalysen
|
||||||
</div>
|
</div>
|
||||||
{singlePrompts.map(p=>(
|
{singlePrompts.map(p=>(
|
||||||
<div key={p.id} className="card section-gap" style={{opacity:p.active?1:0.6}}>
|
<div key={p.id} className="card section-gap">
|
||||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||||
<div style={{flex:1}}>
|
<div style={{flex:1}}>
|
||||||
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
|
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
|
||||||
{SLUG_LABELS[p.slug]||p.name}
|
{SLUG_LABELS[p.slug]||p.name}
|
||||||
{!p.active && <span style={{fontSize:10,color:'#D85A30',
|
{!p.active && <span style={{fontSize:10,color:'var(--text3)',
|
||||||
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}>⏸ Deaktiviert</span>}
|
background:'var(--surface2)',padding:'1px 6px',borderRadius:4}}>Inaktiv</span>}
|
||||||
</div>
|
</div>
|
||||||
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
|
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-secondary" style={{padding:'5px 8px',fontSize:12}}
|
<button className="btn btn-secondary" style={{padding:'5px 8px',fontSize:12}}
|
||||||
onClick={()=>{
|
onClick={()=>fetch(`/api/prompts/${p.id}`,{method:'PUT',
|
||||||
const token = localStorage.getItem('bodytrack_token')||''
|
headers:{'Content-Type':'application/json'},
|
||||||
fetch(`/api/prompts/${p.id}`,{
|
body:JSON.stringify({active:p.active?0:1})}).then(loadAll)}>
|
||||||
method:'PUT',
|
{p.active?'Deaktiv.':'Aktiv.'}
|
||||||
headers:{'Content-Type':'application/json','X-Auth-Token':token},
|
|
||||||
body:JSON.stringify({active:!p.active})
|
|
||||||
}).then(loadAll)
|
|
||||||
}}>
|
|
||||||
{p.active?'Deaktivieren':'Aktivieren'}
|
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
|
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
|
||||||
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
|
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
|
||||||
|
|
@ -386,60 +372,26 @@ export default function Analysis() {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Pipeline prompts */}
|
{/* Pipeline prompts */}
|
||||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',margin:'20px 0 8px'}}>
|
|
||||||
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
|
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
|
||||||
textTransform:'uppercase',letterSpacing:'0.05em'}}>
|
textTransform:'uppercase',letterSpacing:'0.05em',margin:'20px 0 8px'}}>
|
||||||
Mehrstufige Pipeline
|
Mehrstufige Pipeline
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
<div style={{padding:'10px 12px',background:'var(--warn-bg)',borderRadius:8,
|
||||||
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
|
fontSize:12,color:'var(--warn-text)',marginBottom:12,lineHeight:1.6}}>
|
||||||
return pipelinePrompt && (
|
⚠️ <strong>Hinweis:</strong> Pipeline-Stage-1-Prompts müssen valides JSON zurückgeben.
|
||||||
<button className="btn btn-secondary" style={{padding:'5px 12px',fontSize:12}}
|
Halte das JSON-Format im Prompt erhalten. Stage 2 + 3 können frei angepasst werden.
|
||||||
onClick={()=>{
|
|
||||||
const token = localStorage.getItem('bodytrack_token')||''
|
|
||||||
fetch(`/api/prompts/${pipelinePrompt.id}`,{
|
|
||||||
method:'PUT',
|
|
||||||
headers:{'Content-Type':'application/json','X-Auth-Token':token},
|
|
||||||
body:JSON.stringify({active:!pipelinePrompt.active})
|
|
||||||
}).then(loadAll)
|
|
||||||
}}>
|
|
||||||
{pipelinePrompt.active ? 'Gesamte Pipeline deaktivieren' : 'Gesamte Pipeline aktivieren'}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
|
||||||
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
|
|
||||||
const isPipelineActive = pipelinePrompt?.active ?? true
|
|
||||||
return (
|
|
||||||
<div style={{padding:'10px 12px',
|
|
||||||
background: isPipelineActive ? 'var(--warn-bg)' : '#FCEBEB',
|
|
||||||
borderRadius:8,fontSize:12,
|
|
||||||
color: isPipelineActive ? 'var(--warn-text)' : '#D85A30',
|
|
||||||
marginBottom:12,lineHeight:1.6}}>
|
|
||||||
{isPipelineActive ? (
|
|
||||||
<>⚠️ <strong>Hinweis:</strong> 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.</>
|
|
||||||
) : (
|
|
||||||
<>⏸ <strong>Pipeline deaktiviert:</strong> Die mehrstufige Gesamtanalyse ist aktuell nicht verfügbar.
|
|
||||||
Aktiviere sie mit dem Schalter oben, um sie auf der Analyse-Seite zu nutzen.</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{pipelinePrompts.map(p=>{
|
{pipelinePrompts.map(p=>{
|
||||||
const isJson = jsonSlugs.includes(p.slug)
|
const isJson = jsonSlugs.includes(p.slug)
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="card section-gap"
|
<div key={p.id} className="card section-gap"
|
||||||
style={{borderLeft:`3px solid ${isJson?'var(--warn)':'var(--accent)'}`,opacity:p.active?1:0.6}}>
|
style={{borderLeft:`3px solid ${isJson?'var(--warn)':'var(--accent)'}`}}>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||||
<div style={{flex:1}}>
|
<div style={{flex:1}}>
|
||||||
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
|
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
|
||||||
{p.name}
|
{p.name}
|
||||||
{isJson && <span style={{fontSize:10,background:'var(--warn-bg)',
|
{isJson && <span style={{fontSize:10,background:'var(--warn-bg)',
|
||||||
color:'var(--warn-text)',padding:'1px 6px',borderRadius:4}}>JSON-Output</span>}
|
color:'var(--warn-text)',padding:'1px 6px',borderRadius:4}}>JSON-Output</span>}
|
||||||
{!p.active && <span style={{fontSize:10,color:'#D85A30',
|
|
||||||
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}>⏸ Deaktiviert</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
|
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ function PeriodSelector({ value, onChange }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Body Section (Weight + Composition combined) ──────────────────────────────
|
// ── Body Section (Weight + Composition combined) ──────────────────────────────
|
||||||
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug }) {
|
||||||
const [period, setPeriod] = useState(90)
|
const [period, setPeriod] = useState(90)
|
||||||
const sex = profile?.sex||'m'
|
const sex = profile?.sex||'m'
|
||||||
const height = profile?.height||178
|
const height = profile?.height||178
|
||||||
|
|
@ -394,14 +394,14 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline','koerper','gesundheit','ziele'])}
|
<InsightBox insights={insights} slugs={['pipeline','koerper','gesundheit','ziele']}
|
||||||
onRequest={onRequest} loading={loadingSlug}/>
|
onRequest={onRequest} loading={loadingSlug}/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
||||||
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug }) {
|
||||||
const [period, setPeriod] = useState(30)
|
const [period, setPeriod] = useState(30)
|
||||||
if (!nutrition?.length) return (
|
if (!nutrition?.length) return (
|
||||||
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
|
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
|
||||||
|
|
@ -579,13 +579,13 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
||||||
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
||||||
</div>
|
</div>
|
||||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
<InsightBox insights={insights} slugs={['ernaehrung']} onRequest={onRequest} loading={loadingSlug}/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Activity Section ──────────────────────────────────────────────────────────
|
// ── Activity Section ──────────────────────────────────────────────────────────
|
||||||
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
function ActivitySection({ activities, insights, onRequest, loadingSlug }) {
|
||||||
const [period, setPeriod] = useState(30)
|
const [period, setPeriod] = useState(30)
|
||||||
if (!activities?.length) return (
|
if (!activities?.length) return (
|
||||||
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
|
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
|
||||||
|
|
@ -657,13 +657,13 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
||||||
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
||||||
</div>
|
</div>
|
||||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
|
<InsightBox insights={insights} slugs={['aktivitaet']} onRequest={onRequest} loading={loadingSlug}/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Correlation Section ───────────────────────────────────────────────────────
|
// ── Correlation Section ───────────────────────────────────────────────────────
|
||||||
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) {
|
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug }) {
|
||||||
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
|
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
|
||||||
if (filtered.length < 5) return (
|
if (filtered.length < 5) return (
|
||||||
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/>
|
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/>
|
||||||
|
|
@ -852,7 +852,7 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt','ziele'])} onRequest={onRequest} loading={loadingSlug}/>
|
<InsightBox insights={insights} slugs={['gesamt','ziele']} onRequest={onRequest} loading={loadingSlug}/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -903,7 +903,6 @@ export default function History() {
|
||||||
const [activities, setActivities] = useState([])
|
const [activities, setActivities] = useState([])
|
||||||
const [corrData, setCorrData] = useState([])
|
const [corrData, setCorrData] = useState([])
|
||||||
const [insights, setInsights] = useState([])
|
const [insights, setInsights] = useState([])
|
||||||
const [prompts, setPrompts] = useState([])
|
|
||||||
const [profile, setProfile] = useState(null)
|
const [profile, setProfile] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [loadingSlug,setLoadingSlug]= useState(null)
|
const [loadingSlug,setLoadingSlug]= useState(null)
|
||||||
|
|
@ -912,12 +911,10 @@ export default function History() {
|
||||||
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
||||||
api.listNutrition(90), api.listActivity(200),
|
api.listNutrition(90), api.listActivity(200),
|
||||||
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
||||||
api.listPrompts(),
|
]).then(([w,ca,ci,n,a,corr,ins,p])=>{
|
||||||
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
|
||||||
setWeights(w); setCalipers(ca); setCircs(ci)
|
setWeights(w); setCalipers(ca); setCircs(ci)
|
||||||
setNutrition(n); setActivities(a); setCorrData(corr)
|
setNutrition(n); setActivities(a); setCorrData(corr)
|
||||||
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
||||||
setPrompts(Array.isArray(pr)?pr:[])
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -926,23 +923,17 @@ export default function History() {
|
||||||
const requestInsight = async (slug) => {
|
const requestInsight = async (slug) => {
|
||||||
setLoadingSlug(slug)
|
setLoadingSlug(slug)
|
||||||
try {
|
try {
|
||||||
const result = await api.runInsight(slug)
|
const pid=localStorage.getItem('bodytrack_active_profile')||''
|
||||||
// result is already JSON, not a Response object
|
const r=await api.runInsight(slug)
|
||||||
const ins = await api.latestInsights()
|
if(!r.ok) throw new Error(await r.text())
|
||||||
|
const ins=await api.latestInsights()
|
||||||
setInsights(Array.isArray(ins)?ins:[])
|
setInsights(Array.isArray(ins)?ins:[])
|
||||||
} catch(e){
|
} catch(e){ alert('KI-Fehler: '+e.message) }
|
||||||
alert('KI-Fehler: '+e.message)
|
|
||||||
}
|
|
||||||
finally{ setLoadingSlug(null) }
|
finally{ setLoadingSlug(null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if(loading) return <div className="empty-state"><div className="spinner"/></div>
|
if(loading) return <div className="empty-state"><div className="spinner"/></div>
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default function LoginScreen() {
|
||||||
}
|
}
|
||||||
setLoading(true); setError(null)
|
setLoading(true); setError(null)
|
||||||
try {
|
try {
|
||||||
await login({ email: email.trim().toLowerCase(), password: password })
|
await login({ email: email.trim().toLowerCase(), pin: password })
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
setError(e.message || 'Ungültige E-Mail oder Passwort')
|
setError(e.message || 'Ungültige E-Mail oder Passwort')
|
||||||
} finally { setLoading(false) }
|
} finally { setLoading(false) }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
|
import { Save, Download, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { Avatar } from './ProfileSelect'
|
import { Avatar } from './ProfileSelect'
|
||||||
|
|
@ -123,73 +123,6 @@ export default function SettingsPage() {
|
||||||
// editingId: string ID of profile being edited, or 'new' for new profile, or null
|
// editingId: string ID of profile being edited, or 'new' for new profile, or null
|
||||||
const [editingId, setEditingId] = useState(null)
|
const [editingId, setEditingId] = useState(null)
|
||||||
const [saved, setSaved] = useState(false)
|
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 handleSave = async (form, profileId) => {
|
||||||
const data = {}
|
const data = {}
|
||||||
|
|
@ -358,12 +291,12 @@ export default function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
{canExport && <>
|
{canExport && <>
|
||||||
<button className="btn btn-primary btn-full"
|
<button className="btn btn-primary btn-full"
|
||||||
onClick={()=>api.exportZip()}>
|
onClick={()=>window.open('/api/export/zip')}>
|
||||||
<Download size={14}/> ZIP exportieren
|
<Download size={14}/> ZIP exportieren
|
||||||
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— je eine CSV pro Kategorie</span>
|
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— je eine CSV pro Kategorie</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary btn-full"
|
<button className="btn btn-secondary btn-full"
|
||||||
onClick={()=>api.exportJson()}>
|
onClick={()=>window.open('/api/export/json')}>
|
||||||
<Download size={14}/> JSON exportieren
|
<Download size={14}/> JSON exportieren
|
||||||
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— maschinenlesbar, alles in einer Datei</span>
|
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— maschinenlesbar, alles in einer Datei</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -374,55 +307,6 @@ export default function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import */}
|
|
||||||
<div className="card section-gap">
|
|
||||||
<div className="card-title">Backup importieren</div>
|
|
||||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
|
|
||||||
Importiere einen ZIP-Export zurück in <strong>{activeProfile?.name}</strong>.
|
|
||||||
Vorhandene Einträge werden nicht überschrieben.
|
|
||||||
</p>
|
|
||||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
|
||||||
{!canExport && (
|
|
||||||
<div style={{padding:'10px 12px',background:'#FCEBEB',borderRadius:8,
|
|
||||||
fontSize:13,color:'#D85A30',marginBottom:8}}>
|
|
||||||
🔒 Import ist für dein Profil nicht freigeschaltet. Bitte den Admin kontaktieren.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{canExport && (
|
|
||||||
<>
|
|
||||||
<label className="btn btn-primary btn-full"
|
|
||||||
style={{cursor:importing?'wait':'pointer',opacity:importing?0.6:1}}>
|
|
||||||
<input type="file" accept=".zip" onChange={handleImport}
|
|
||||||
disabled={importing}
|
|
||||||
style={{display:'none'}}/>
|
|
||||||
{importing ? (
|
|
||||||
<>Importiere...</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload size={14}/> ZIP-Backup importieren
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
{importMsg && (
|
|
||||||
<div style={{
|
|
||||||
padding:'10px 12px',
|
|
||||||
background: importMsg.type === 'success' ? '#E1F5EE' : '#FCEBEB',
|
|
||||||
borderRadius:8,
|
|
||||||
fontSize:12,
|
|
||||||
color: importMsg.type === 'success' ? 'var(--accent)' : '#D85A30',
|
|
||||||
lineHeight:1.4
|
|
||||||
}}>
|
|
||||||
{importMsg.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
|
|
||||||
Der Import erkennt automatisch das Format und importiert nur neue Einträge.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{saved && (
|
{saved && (
|
||||||
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
|
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
|
||||||
background:'var(--accent)',color:'white',padding:'8px 20px',borderRadius:20,
|
background:'var(--accent)',color:'white',padding:'8px 20px',borderRadius:20,
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,7 @@ export const api = {
|
||||||
return fetch(`${BASE}/photos`,{method:'POST',body:fd,headers:hdrs()}).then(r=>r.json())
|
return fetch(`${BASE}/photos`,{method:'POST',body:fd,headers:hdrs()}).then(r=>r.json())
|
||||||
},
|
},
|
||||||
listPhotos: () => req('/photos'),
|
listPhotos: () => req('/photos'),
|
||||||
photoUrl: (pid) => {
|
photoUrl: (pid) => `${BASE}/photos/${pid}`,
|
||||||
const token = getToken()
|
|
||||||
return `${BASE}/photos/${pid}${token ? `?token=${token}` : ''}`
|
|
||||||
},
|
|
||||||
|
|
||||||
// Nutrition
|
// Nutrition
|
||||||
importCsv: async(file)=>{
|
importCsv: async(file)=>{
|
||||||
|
|
@ -91,45 +88,9 @@ export const api = {
|
||||||
insightPipeline: () => req('/insights/pipeline',{method:'POST'}),
|
insightPipeline: () => req('/insights/pipeline',{method:'POST'}),
|
||||||
listInsights: () => req('/insights'),
|
listInsights: () => req('/insights'),
|
||||||
latestInsights: () => req('/insights/latest'),
|
latestInsights: () => req('/insights/latest'),
|
||||||
exportZip: async () => {
|
exportZip: () => window.open(`${BASE}/export/zip`),
|
||||||
const res = await fetch(`${BASE}/export/zip`, {headers: hdrs()})
|
exportJson: () => window.open(`${BASE}/export/json`),
|
||||||
if (!res.ok) throw new Error('Export failed')
|
exportCsv: () => window.open(`${BASE}/export/csv`),
|
||||||
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
|
// Admin
|
||||||
adminListProfiles: () => req('/admin/profiles'),
|
adminListProfiles: () => req('/admin/profiles'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user