mitai-jinkendo/.claude/docs/technical/DATABASE.md
Lars 7940dc7560 docs: Struktur .claude/docs versionieren, working/, Gitea-Index, Regeln
- .gitignore: .claude/docs, rules, commands tracken; settings.local weiter ignorieren
- DOCUMENTATION.md: verbindliche Ablage functional/technical/working/issues
- .claude/README.md: Agent-Einstieg; GITEA_ISSUES_INDEX aus MCP (Stand 2026-04-08)
- Arbeitspapiere von docs/ nach .claude/docs/working/ verschoben
- docs/MEMBERSHIP_SYSTEM.md als Stub; kanonisch technical/MEMBERSHIP_SYSTEM.md
- CLAUDE.md Pflichtlektüre und Links angepasst; docs/README.md vereinfacht

Made-with: Cursor
2026-04-08 13:01:49 +02:00

1089 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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