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

34 KiB
Raw Permalink Blame 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

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_logvitals_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

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:

CREATE TRIGGER trigger_profiles_updated
    BEFORE UPDATE ON profiles
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_timestamp();

2. sessions

Beschreibung: Auth-Token-Management

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

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:

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)

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

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)

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

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

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

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

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)

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:

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

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

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

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

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:

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

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

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:

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

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

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

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

{
  "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

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

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:

[
  {"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

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)

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)

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:

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:

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:

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

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

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

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

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)

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)