# 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)