mitai-jinkendo/.claude/docs/technical/DATABASE.md

1089 lines
34 KiB
Markdown
Raw Normal View History

# Datenbankschema
## Übersicht
**Datenbank:** PostgreSQL 16 Alpine
**Schema-Verwaltung:** Automatische Migrations (db_init.py)
**Tabellen:** 32 Tabellen (Stand v9d Phase 2)
**Primärschlüssel:** UUID (uuid_generate_v4())
**Zeitstempel:** `TIMESTAMP WITH TIME ZONE`, UTC
---
## Migrations-System
### Automatische Schema-Updates
**Location:** `backend/migrations/*.sql`
**Pattern:** `XXX_description.sql` (z.B. `001_initial_schema.sql`)
**Tracking-Tabelle:** `schema_migrations`
```sql
CREATE TABLE schema_migrations (
id SERIAL PRIMARY KEY,
migration_file VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
**Ablauf:**
1. Container-Start → `db_init.py` läuft
2. Alle Dateien in `backend/migrations/` werden alphabetisch sortiert
3. Noch nicht angewendete Migrationen werden ausgeführt
4. Eintrag in `schema_migrations` nach erfolgreicher Anwendung
**Sicherheit:**
- Transaktionen pro Migration (Rollback bei Fehler)
- Skip bereits angewendeter Migrationen
- Logging aller Migrationen
### Migration-History
| Nr | Datei | Beschreibung | Version |
|----|-------|--------------|---------|
| 001 | `001_initial_schema.sql` | Basis-Tabellen (profiles, sessions, weight, etc.) | v9b |
| 002 | `002_membership_system.sql` | Tiers, Features, Coupons, Access Grants | v9c |
| 003 | `003_email_verification.sql` | E-Mail-Verifizierung für Registrierung | v9c |
| 004 | `004_training_types.sql` | Trainingstypen (23 Basis-Typen) | v9d Phase 1a |
| 005 | `005_extended_training_types.sql` | Extended Types (Gehen, Tanzen, Meditation) | v9d Phase 1a |
| 006 | `006_training_abilities.sql` | abilities JSONB column (Platzhalter) | v9d Phase 1b |
| 007 | `007_activity_type_mappings.sql` | Lernendes Mapping-System | v9d Phase 1b |
| 008-009 | – | Reserved für v9d Phase 2a-c | – |
| 010 | `010_sleep_log.sql` | Schlaf-Modul (JSONB segments) | v9d Phase 2b |
| 011 | `011_rest_days.sql` | Ruhetage (Kraft, Cardio, Entspannung) | v9d Phase 2a |
| 012 | `012_rest_days_unique_constraint.sql` | Unique constraint rest_days | v9d Phase 2a |
| 013 | `013_vitals_log.sql` | Vitalwerte (Ruhepuls, HRV) – **deprecated** | v9d Phase 2c |
| 014 | `014_extended_vitals.sql` | Extended vitals (BP, VO2 Max, SpO2) – **deprecated** | v9d Phase 2c |
| 015 | `015_vitals_refactoring.sql` | **Vitals Refactoring** - Trennung in vitals_baseline + blood_pressure_log | v9d Phase 2d |
**Backup vor Migration 015:**
- `vitals_log` → `vitals_log_backup_pre_015`
- Daten nach Refactoring archiviert (nicht gelöscht)
---
## Tabellen-Übersicht
### Core-Tabellen
| Tabelle | Zweck | Primärschlüssel | Besonderheit |
|---------|-------|-----------------|--------------|
| `profiles` | Nutzerprofile + Auth | UUID | bcrypt-Hashing, Tier-System |
| `sessions` | Auth-Tokens | VARCHAR(64) | Expires-Check via Index |
| `ai_usage` | KI-Call-Tracking | UUID | Daily Count pro Profil |
### Tracking-Tabellen
| Tabelle | Zweck | Primärschlüssel | Unique Constraint |
|---------|-------|-----------------|-------------------|
| `weight_log` | Gewichtsmessungen | UUID | (profile_id, date) |
| `circumference_log` | Umfangsmessungen (8 Punkte) | UUID | – |
| `caliper_log` | Hautfaltenmessungen + BF% | UUID | – |
| `nutrition_log` | Ernährungsdaten (Kalorien + Makros) | UUID | – |
| `activity_log` | Training + Aktivitäten | UUID | – |
| `photos` | Progress-Fotos | UUID | – |
| `sleep_log` | Schlaf + JSONB Phasen | UUID | (profile_id, date) |
| `rest_days` | Multi-dimensionale Ruhetage | UUID | (profile_id, date, rest_type) |
| `vitals_baseline` | Morgenmessungen (RHR, HRV, VO2 Max) | UUID | (profile_id, date) |
| `blood_pressure_log` | Blutdruck mehrfach täglich | UUID | – |
### KI-Tabellen
| Tabelle | Zweck | Primärschlüssel | Index |
|---------|-------|-----------------|-------|
| `ai_insights` | KI-Auswertungen | UUID | (profile_id, scope, created DESC) |
| `ai_prompts` | Konfigurierbare Prompts | UUID | slug UNIQUE |
### Membership-System (v9c)
| Tabelle | Zweck | Primärschlüssel | Besonderheit |
|---------|-------|-----------------|--------------|
| `subscriptions` | **deprecated** | UUID | Wurde durch access_grants ersetzt |
| `coupons` | Coupon-Codes | UUID | code UNIQUE |
| `coupon_redemptions` | Einlösungen | UUID | (profile_id, coupon_id) UNIQUE |
| `features` | Feature-Definitionen | VARCHAR(50) | id als String (z.B. 'weight_entries') |
| `tier_limits` | Tier-Feature-Matrix | UUID | (tier_id, feature_id) UNIQUE |
| `user_feature_restrictions` | User-spezifische Limits | UUID | (profile_id, feature_id) UNIQUE |
| `user_feature_usage` | Usage-Tracking | UUID | (profile_id, feature_id) UNIQUE |
| `access_grants` | Zeitlich begrenzte Tier-Zugriffe | UUID | Ersetzt subscriptions |
| `user_activity_log` | Audit-Log | UUID | – |
### Training-System (v9d)
| Tabelle | Zweck | Primärschlüssel | Besonderheit |
|---------|-------|-----------------|--------------|
| `training_types` | 29 Trainingstypen in 7 Kategorien | UUID | abilities JSONB |
| `activity_type_mappings` | Lernendes Mapping-System | UUID | (activity_type, profile_id) UNIQUE |
### Infrastruktur
| Tabelle | Zweck | Primärschlüssel | Verwendung |
|---------|-------|-----------------|------------|
| `schema_migrations` | Migrations-Tracking | SERIAL | Automatisches System |
---
## Detaillierte Tabellen-Beschreibungen
### 1. profiles
**Beschreibung:** Nutzerprofile mit Auth, Permissions, Tier-System
```sql
CREATE TABLE 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, -- bcrypt-Hash
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,
-- E-Mail-Verifizierung (v9c)
email_verified BOOLEAN DEFAULT FALSE,
verification_token VARCHAR(64),
verification_expires TIMESTAMP WITH TIME ZONE,
-- Tier-System (v9c)
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 idx_profiles_email ON profiles(email) WHERE email IS NOT NULL;
CREATE INDEX idx_profiles_tier ON profiles(tier);
```
**Wichtige Felder:**
- `pin_hash`: bcrypt-Hash (Format: `$2b$12$...`) oder Legacy SHA256 (auto-migrated)
- `tier`: Subscription-Tier (überschrieben durch `access_grants`)
- `trial_ends_at`: 14 Tage ab Registrierung
- `email_verified`: Muss `true` sein für Login (außer Legacy-Accounts)
**Trigger:**
```sql
CREATE TRIGGER trigger_profiles_updated
BEFORE UPDATE ON profiles
FOR EACH ROW
EXECUTE FUNCTION update_updated_timestamp();
```
---
### 2. sessions
**Beschreibung:** Auth-Token-Management
```sql
CREATE TABLE sessions (
token VARCHAR(64) PRIMARY KEY, -- secrets.token_urlsafe(32)
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 idx_sessions_profile_id ON sessions(profile_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
```
**Besonderheiten:**
- Token-Format: 43 Zeichen Base64-URL-safe
- Password-Reset-Tokens: Präfix `reset_` (z.B. `reset_jT9z3xK...`)
- Automatische Bereinigung via `WHERE expires_at > CURRENT_TIMESTAMP`
---
### 3. weight_log
**Beschreibung:** Gewichtsmessungen
```sql
CREATE TABLE 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', -- 'manual' | 'apple_health' | 'garmin'
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_weight_log_profile_date ON weight_log(profile_id, date DESC);
CREATE UNIQUE INDEX idx_weight_log_profile_date_unique ON weight_log(profile_id, date);
```
**Unique Constraint:** Ein Eintrag pro Profil pro Tag
**Upsert-Logik:**
```sql
INSERT INTO weight_log (profile_id, date, weight, note, source)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (profile_id, date)
DO UPDATE SET weight=EXCLUDED.weight, note=EXCLUDED.note
WHERE weight_log.source != 'manual'; -- Manuelle Einträge haben Vorrang
```
---
### 4. circumference_log
**Beschreibung:** Umfangsmessungen (8 Punkte)
```sql
CREATE TABLE 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 idx_circumference_profile_date ON circumference_log(profile_id, date DESC);
```
**Kein Unique Constraint:** Mehrere Messungen pro Tag möglich
---
### 5. caliper_log
**Beschreibung:** Hautfaltenmessungen + Körperfett-Berechnung
```sql
CREATE TABLE 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', -- 'jackson3' | 'jackson7' | 'durnin' | 'parrillo'
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), -- Berechnet
lean_mass NUMERIC(5,2), -- Berechnet
fat_mass NUMERIC(5,2), -- Berechnet
notes TEXT,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_caliper_profile_date ON caliper_log(profile_id, date DESC);
```
**Berechnungslogik:** Backend berechnet `body_fat_pct`, `lean_mass`, `fat_mass` bei POST
**Methoden:** Siehe `frontend/src/utils/calc.js` (Jackson-Pollock 3/7, Durnin-Womersley, Parrillo)
---
### 6. nutrition_log
**Beschreibung:** Ernährungsdaten (Kalorien + Makros)
```sql
CREATE TABLE 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', -- 'csv' | 'manual'
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_nutrition_profile_date ON nutrition_log(profile_id, date DESC);
```
**Upsert-Logik:** Wie `weight_log` – ein Eintrag pro Tag
---
### 7. activity_log
**Beschreibung:** Training + Aktivitäten
```sql
CREATE TABLE 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, -- 'Laufen' | 'Krafttraining' | etc.
training_type_id UUID REFERENCES training_types(id), -- v9d: Mapping zu Trainingstypen
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), -- Rate of Perceived Exertion
source VARCHAR(20) DEFAULT 'manual',
notes TEXT,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_activity_profile_date ON activity_log(profile_id, date DESC);
CREATE INDEX idx_activity_training_type ON activity_log(training_type_id);
```
**Duplikat-Erkennung:** Import prüft `(date, start_time)` bei Apple Health CSV
---
### 8. photos
**Beschreibung:** Progress-Fotos
```sql
CREATE TABLE 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 circumference/caliper
date DATE,
path TEXT NOT NULL, -- Filesystem-Pfad (relativ zu PHOTOS_DIR)
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_photos_profile_date ON photos(profile_id, date DESC);
```
**Storage:** `/app/photos/` (Docker Volume)
**Format:** JPEG, max 5 MB (Frontend-Validierung)
---
### 9. ai_insights
**Beschreibung:** KI-Auswertungen
```sql
CREATE TABLE 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, -- Prompt-Slug (z.B. 'weight-trend', 'pipeline')
content TEXT NOT NULL, -- Markdown-formatierter Text
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_ai_insights_profile_scope ON ai_insights(profile_id, scope, created DESC);
```
**Scopes:** `weight-trend`, `nutrition-analysis`, `training-plan`, `body-composition`, `progress-summary`, `pipeline`
---
### 10. ai_prompts
**Beschreibung:** Konfigurierbare KI-Prompts
```sql
CREATE TABLE 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, -- Prompt-Template mit Platzhaltern
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 idx_ai_prompts_slug ON ai_prompts(slug);
CREATE INDEX idx_ai_prompts_active_sort ON ai_prompts(active, sort_order);
```
**Template-Platzhalter:** `{weight_data}`, `{nutrition_data}`, `{activity_data}`, etc.
**Trigger:** `update_updated_timestamp()` bei UPDATE
---
### 11. ai_usage
**Beschreibung:** KI-Call-Tracking (Daily)
```sql
CREATE TABLE 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 idx_ai_usage_profile_date ON ai_usage(profile_id, date);
```
**Upsert-Logik:**
```sql
INSERT INTO ai_usage (profile_id, date, call_count)
VALUES (%s, CURRENT_DATE, 1)
ON CONFLICT (profile_id, date)
DO UPDATE SET call_count = ai_usage.call_count + 1;
```
---
## Membership-System (v9c)
### 12. coupons
```sql
CREATE TABLE coupons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(50) NOT NULL UNIQUE,
tier_id VARCHAR(20) NOT NULL, -- 'basic' | 'premium' | 'selfhosted'
valid_days INTEGER NOT NULL, -- Gültigkeitsdauer in Tagen
max_uses INTEGER, -- NULL = unlimited
uses_count INTEGER DEFAULT 0,
expires_at TIMESTAMP WITH TIME ZONE,
active BOOLEAN DEFAULT TRUE,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_coupons_code ON coupons(code);
```
---
### 13. coupon_redemptions
```sql
CREATE TABLE coupon_redemptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, coupon_id)
);
CREATE INDEX idx_coupon_redemptions_profile ON coupon_redemptions(profile_id);
CREATE INDEX idx_coupon_redemptions_coupon ON coupon_redemptions(coupon_id);
```
**Logik:** Ein User kann denselben Coupon nur einmal einlösen
---
### 14. features
```sql
CREATE TABLE features (
id VARCHAR(50) PRIMARY KEY, -- 'weight_entries', 'ai_calls', etc.
name VARCHAR(100) NOT NULL,
description TEXT,
limit_type VARCHAR(20) NOT NULL CHECK (limit_type IN ('count', 'boolean')),
reset_period VARCHAR(20) NOT NULL CHECK (reset_period IN ('never', 'daily', 'monthly')),
default_limit INTEGER, -- NULL = unlimited
active BOOLEAN DEFAULT TRUE,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
**Standard-Features:**
- `weight_entries`, `circumference_entries`, `caliper_entries`, `activity_entries`, `nutrition_entries`
- `photos`, `ai_calls`, `ai_pipeline`, `data_export`, `data_import`
---
### 15. tier_limits
```sql
CREATE TABLE tier_limits (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tier_id VARCHAR(20) NOT NULL, -- 'free', 'basic', 'premium', 'selfhosted'
feature_id VARCHAR(50) NOT NULL REFERENCES features(id) ON DELETE CASCADE,
limit_value INTEGER, -- NULL = unlimited
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tier_id, feature_id)
);
CREATE INDEX idx_tier_limits_tier ON tier_limits(tier_id);
CREATE INDEX idx_tier_limits_feature ON tier_limits(feature_id);
```
**Beispiel-Werte:**
```sql
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('free', 'weight_entries', 100),
('free', 'ai_calls', 10),
('premium', 'weight_entries', NULL), -- unlimited
('premium', 'ai_calls', NULL);
```
---
### 16. user_feature_restrictions
```sql
CREATE TABLE user_feature_restrictions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
feature_id VARCHAR(50) NOT NULL REFERENCES features(id) ON DELETE CASCADE,
limit_value INTEGER, -- Überschreibt Tier-Limit
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, feature_id)
);
CREATE INDEX idx_user_restrictions_profile ON user_feature_restrictions(profile_id);
```
**Priorität:** User-Restriction > Tier-Limit > Feature-Default
---
### 17. user_feature_usage
```sql
CREATE TABLE user_feature_usage (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
feature_id VARCHAR(50) NOT NULL REFERENCES features(id) ON DELETE CASCADE,
usage_count INTEGER DEFAULT 0,
reset_at TIMESTAMP WITH TIME ZONE,
updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, feature_id)
);
CREATE INDEX idx_user_usage_profile ON user_feature_usage(profile_id);
CREATE INDEX idx_user_usage_reset ON user_feature_usage(reset_at);
```
**Upsert-Logik:**
```sql
INSERT INTO user_feature_usage (profile_id, feature_id, usage_count, reset_at)
VALUES (%s, %s, 1, %s)
ON CONFLICT (profile_id, feature_id)
DO UPDATE SET usage_count = user_feature_usage.usage_count + 1, updated = CURRENT_TIMESTAMP;
```
---
### 18. access_grants
```sql
CREATE TABLE access_grants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
tier_id VARCHAR(20) NOT NULL,
valid_from TIMESTAMP WITH TIME ZONE NOT NULL,
valid_until TIMESTAMP WITH TIME ZONE NOT NULL,
source VARCHAR(50), -- 'coupon', 'trial', 'manual', 'gift'
is_active BOOLEAN DEFAULT TRUE,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_access_grants_profile ON access_grants(profile_id);
CREATE INDEX idx_access_grants_validity ON access_grants(valid_from, valid_until);
```
**Logik:** Aktiver Grant überschreibt `profiles.tier`
**Priorität:** Grant mit spätestem `valid_until` gewinnt
---
### 19. user_activity_log
```sql
CREATE TABLE user_activity_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
action VARCHAR(100) NOT NULL,
details TEXT,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_activity_log_profile ON user_activity_log(profile_id);
CREATE INDEX idx_activity_log_created ON user_activity_log(created DESC);
```
**Verwendung:** Audit-Log (Login, Coupon-Einlösung, Feature-Zugriff-Denial)
---
## Training-System (v9d)
### 20. training_types
```sql
CREATE TABLE training_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL,
category VARCHAR(50) NOT NULL, -- 'Kraft', 'Cardio', 'Flexibilität', etc.
color VARCHAR(7) DEFAULT '#1D9E75',
icon VARCHAR(50), -- 'dumbbell', 'running', 'yoga', etc.
abilities JSONB, -- v9f: Fähigkeiten-Matrix
sort_order INTEGER DEFAULT 0,
active BOOLEAN DEFAULT TRUE,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_training_types_category ON training_types(category);
CREATE INDEX idx_training_types_active ON training_types(active);
```
**abilities Format (v9f):**
```json
{
"Kraft": {"Oberkörper": 8, "Unterkörper": 2, "Core": 5},
"Ausdauer": {"Aerob": 9, "Anaerob": 3},
"Beweglichkeit": {"Dynamisch": 6, "Statisch": 4},
"Koordination": 7,
"Schnelligkeit": 5,
"Gleichgewicht": 3
}
```
**Kategorien:**
- Kraft (8 Typen)
- Cardio (6 Typen)
- Flexibilität (4 Typen)
- Spiel & Sport (4 Typen)
- Alltag & Bewegung (3 Typen)
- Outdoor & Natur (2 Typen)
- Geist & Meditation (2 Typen)
**Gesamt:** 29 Trainingstypen
---
### 21. activity_type_mappings
```sql
CREATE TABLE activity_type_mappings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
activity_type VARCHAR(100) NOT NULL, -- 'Laufen', 'Running', etc.
training_type_id UUID NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
profile_id UUID REFERENCES profiles(id) ON DELETE CASCADE, -- NULL = global
is_global BOOLEAN DEFAULT FALSE,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(activity_type, profile_id)
);
CREATE INDEX idx_activity_mappings_activity ON activity_type_mappings(activity_type);
CREATE INDEX idx_activity_mappings_training ON activity_type_mappings(training_type_id);
CREATE INDEX idx_activity_mappings_profile ON activity_type_mappings(profile_id);
```
**Lernendes System:**
- Bulk-Kategorisierung in UI speichert neue Mappings
- User-spezifische Mappings überschreiben globale Mappings
- Admin kann globale Mappings erstellen
**Standard-Mappings:** 40+ vordefiniert (Deutsch + Englisch)
---
## Sleep & Vitals (v9d Phase 2)
### 22. sleep_log
```sql
CREATE TABLE sleep_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
bedtime TIME,
wakeup TIME,
duration_min INTEGER NOT NULL,
quality INTEGER CHECK (quality >= 1 AND quality <= 10),
sleep_segments JSONB, -- Schlafphasen (Deep, REM, Light, Awake)
notes TEXT,
source VARCHAR(20) DEFAULT 'manual',
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, date)
);
CREATE INDEX idx_sleep_profile_date ON sleep_log(profile_id, date DESC);
```
**sleep_segments Format:**
```json
[
{"phase": "deep", "start": "23:30", "end": "01:15"},
{"phase": "rem", "start": "01:15", "end": "02:45"},
{"phase": "light", "start": "02:45", "end": "06:00"},
{"phase": "awake", "start": "06:00", "end": "06:15"}
]
```
---
### 23. rest_days
```sql
CREATE TABLE rest_days (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
rest_type VARCHAR(20) NOT NULL CHECK (rest_type IN ('kraft', 'cardio', 'entspannung')),
reason TEXT,
notes TEXT,
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, date, rest_type)
);
CREATE INDEX idx_rest_days_profile_date ON rest_days(profile_id, date DESC);
```
**Multi-Dimensional Rest:** Mehrere rest_types pro Tag möglich (z.B. kraft + cardio)
---
### 24. vitals_baseline
**Beschreibung:** Morgenmessungen (1x täglich)
```sql
CREATE TABLE vitals_baseline (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
resting_hr INTEGER, -- Ruhepuls (bpm)
hrv INTEGER, -- Herzfrequenzvariabilität (ms)
vo2_max NUMERIC(5,2), -- VO2 Max (ml/kg/min)
spo2 INTEGER, -- Sauerstoffsättigung (%)
respiratory_rate INTEGER, -- Atemfrequenz (pro Minute)
notes TEXT,
source VARCHAR(20) DEFAULT 'manual',
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, date)
);
CREATE INDEX idx_vitals_baseline_profile_date ON vitals_baseline(profile_id, date DESC);
```
**Messung:** Morgens nüchtern, direkt nach dem Aufwachen
---
### 25. blood_pressure_log
**Beschreibung:** Blutdruck (mehrfach täglich)
```sql
CREATE TABLE blood_pressure_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
time TIME NOT NULL,
systolic INTEGER NOT NULL, -- Systolisch (mmHg)
diastolic INTEGER NOT NULL, -- Diastolisch (mmHg)
pulse INTEGER, -- Puls (bpm)
context VARCHAR(50), -- 'fasting', 'after_meal', 'exercise', etc.
irregular_heartbeat BOOLEAN DEFAULT FALSE,
afib_warning BOOLEAN DEFAULT FALSE,
notes TEXT,
source VARCHAR(20) DEFAULT 'manual',
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_blood_pressure_profile_date ON blood_pressure_log(profile_id, date DESC, time DESC);
```
**Contexts:**
- `fasting` – Nüchtern
- `after_meal` – Nach dem Essen
- `exercise` – Nach Training
- `stress` – Unter Stress
- `rest` – In Ruhe
- `before_sleep` – Vor dem Schlafen
- `after_sleep` – Nach dem Aufwachen
- `medication` – Nach Medikation
**WHO/ISH-Klassifizierung:** Backend berechnet Kategorie (Optimal, Normal, Hoch-Normal, Hypertonie 1/2/3)
---
## Beziehungen (Foreign Keys)
### Profil-Bezug
**Alle Tracking-Tabellen:**
```sql
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE
```
**Kaskadierendes Löschen:** Beim Löschen eines Profils werden alle zugehörigen Daten gelöscht
### Membership-Beziehungen
```
profiles
↓ (1:n)
user_feature_usage
user_feature_restrictions
access_grants
coupon_redemptions
features
↓ (1:n)
tier_limits
user_feature_restrictions
user_feature_usage
```
### Training-Beziehungen
```
training_types
↓ (1:n)
activity_log (training_type_id)
activity_type_mappings (training_type_id)
profiles
↓ (1:n)
activity_type_mappings (profile_id, NULL = global)
```
---
## Design-Entscheidungen
### 1. Warum UUID statt Integer?
**Entscheidung:** UUID als Primärschlüssel
**Gründe:**
- Keine Kollisionen bei verteilten Systemen
- Sicherheit (kein Raten von IDs)
- Vorbereitung für Multi-Server-Setup
**Nachteil:** Größerer Index (16 Bytes vs. 4 Bytes)
**Akzeptiert weil:** Performance-Impact minimal bei <100k Einträgen pro Tabelle
---
### 2. Warum JSONB für sleep_segments?
**Entscheidung:** JSONB statt normalisierte Tabellen
**Gründe:**
- Flexibilität (variable Anzahl Phasen pro Nacht)
- Atomarität (gesamter Schlaf = 1 Zeile)
- Performance (kein JOIN nötig)
- Native PostgreSQL-Support für JSON-Queries
**Beispiel-Query:**
```sql
SELECT date, sleep_segments->>0 AS first_phase
FROM sleep_log
WHERE profile_id = '...';
```
---
### 3. Warum Unique Constraint auf (profile_id, date)?
**Entscheidung:** Mehrere Tabellen haben `UNIQUE(profile_id, date)`
**Tabellen:** `weight_log`, `sleep_log`, `vitals_baseline`
**Gründe:**
- Upsert-Logik (nur ein Eintrag pro Tag)
- Vereinfacht Frontend (kein Duplikat-Handling)
- Performance (Index für schnelle Lookups)
**Ausnahmen:** `blood_pressure_log`, `activity_log` (mehrfach täglich erlaubt)
---
### 4. Warum source-Feld?
**Entscheidung:** Alle Import-fähigen Tabellen haben `source VARCHAR(20)`
**Werte:** `manual`, `apple_health`, `garmin`, `withings`, etc.
**Gründe:**
- Provenance-Tracking (woher kommen Daten)
- Konflikt-Auflösung (manuelle Einträge haben Vorrang)
- Debugging (Import-Fehler tracken)
**Upsert-Logik:**
```sql
ON CONFLICT (profile_id, date)
DO UPDATE SET ... WHERE table.source != 'manual';
```
---
### 5. Warum String-IDs für Features?
**Entscheidung:** `features.id` ist `VARCHAR(50)` statt UUID
**Beispiel:** `'weight_entries'`, `'ai_calls'`
**Gründe:**
- Lesbarkeit in Logs/Code
- Einfachere Referenzierung in Frontend
- Keine Notwendigkeit für Auto-Generated IDs
---
### 6. Warum abilities als JSONB?
**Entscheidung:** `training_types.abilities` als JSONB (v9f)
**Gründe:**
- Flexible Schema-Evolution (neue Fähigkeiten ohne Migration)
- Nested Structure (Hierarchie: Kraft → Oberkörper → Brust)
- Native PostgreSQL-Aggregation (AVG, SUM über JSONB)
**Alternative:** Normalisierte Tabellen (abgelehnt wegen Overhead)
---
## Wichtige Queries
### 1. Latest Weight mit 7-Tage-Durchschnitt
```sql
SELECT
w1.date,
w1.weight,
(
SELECT AVG(w2.weight)
FROM weight_log w2
WHERE w2.profile_id = w1.profile_id
AND w2.date BETWEEN w1.date - INTERVAL '7 days' AND w1.date
) AS avg_7d
FROM weight_log w1
WHERE w1.profile_id = %s
ORDER BY w1.date DESC
LIMIT 1;
```
### 2. Feature-Limit-Check mit Hierarchie
```sql
-- 1. User-Restriction (höchste Priorität)
SELECT limit_value FROM user_feature_restrictions
WHERE profile_id = %s AND feature_id = %s;
-- 2. Tier-Limit (falls keine Restriction)
SELECT limit_value FROM tier_limits
WHERE tier_id = (
SELECT tier FROM profiles WHERE id = %s
) AND feature_id = %s;
-- 3. Feature-Default (falls kein Tier-Limit)
SELECT default_limit FROM features WHERE id = %s;
```
### 3. Aktiver Access Grant
```sql
SELECT tier_id FROM access_grants
WHERE profile_id = %s
AND is_active = true
AND valid_from <= CURRENT_TIMESTAMP
AND valid_until > CURRENT_TIMESTAMP
ORDER BY valid_until DESC
LIMIT 1;
```
### 4. Training-Type-Distribution
```sql
SELECT
tt.name,
tt.color,
COUNT(*) AS count,
SUM(al.duration_min) AS total_duration_min
FROM activity_log al
JOIN training_types tt ON al.training_type_id = tt.id
WHERE al.profile_id = %s
AND al.date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY tt.id, tt.name, tt.color
ORDER BY total_duration_min DESC;
```
### 5. Sleep-Schuld (Sleep Debt)
```sql
SELECT
SUM(
CASE
WHEN duration_min < 480 THEN 480 - duration_min -- 8h = 480min Target
ELSE 0
END
) AS sleep_debt_min
FROM sleep_log
WHERE profile_id = %s
AND date >= CURRENT_DATE - INTERVAL '14 days';
```
---
## Zusammenfassung
**Tabellen:** 32 (Stand v9d Phase 2)
**Primärschlüssel:** UUID (uuid_generate_v4())
**Indizes:** 60+ für Performance-Optimierung
**Unique Constraints:** 15+ für Datenintegrität
**JSONB-Spalten:** 2 (`sleep_segments`, `abilities`)
**Migrations:** Automatisch via `db_init.py`
**Design-Highlights:**
- ✅ UUID-basierte IDs (Sicherheit + Skalierbarkeit)
- ✅ JSONB für flexible Strukturen (Schlafphasen, Fähigkeiten)
- ✅ Unique Constraints für Upsert-Logik
- ✅ Source-Tracking für Import-Daten
- ✅ Kaskadierendes Löschen (ON DELETE CASCADE)
- ✅ Automatische Timestamps (created, updated)
- ✅ Check Constraints für Datenvalidierung
- ✅ Index-Optimierung für häufige Queries
**Bekannte Limitationen:**
- Keine Soft-Deletes (physisches Löschen)
- Keine Audit-Trail für Änderungen (außer user_activity_log)
- Keine Partitionierung (akzeptabel bis >1M Einträge)