# Database Schema Final - Exercises System **Version:** 1.2 **Datum:** 2026-04-24 **Status:** REVIEWED - Pending Implementation **Autor:** Claude Code **Änderungen v1.2:** Skill-Level auf benannte Stufen (einsteiger–experte), intensity auf niedrig/mittel/hoch, ai_suggested Felder, summary_ai_generated **Änderungen v1.1:** age_groups JSONB entfernt, Legacy-DROP ergänzt, Migration 017 (Exercise Blocks) --- ## 1. Übersicht **Neue Migrationen:** - **Migration 014:** Variant Progression System + Search Vector + Legacy-Cleanup - **Migration 015:** Semantic Matching (OPTIONAL - Phase 2) - **Migration 016:** Saved Searches - **Migration 017:** Exercise Blocks + Template Blocks **Basis:** Migrationen 001-013 (bereits deployed) --- ## 2. Migration 014: Variant Progression + Search ### 2.1 Vollständige Migration ```sql -- Migration 014: Variant Progression System + Search Vector -- Autor: Claude Code -- Datum: 2026-04-24 DO $$ BEGIN -- ============================================================================ -- VARIANT PROGRESSION -- ============================================================================ -- Erweitere exercise_variants Tabelle ALTER TABLE exercise_variants ADD COLUMN IF NOT EXISTS progression_level INT DEFAULT 1 CHECK (progression_level BETWEEN 1 AND 10), ADD COLUMN IF NOT EXISTS sequence_order INT, ADD COLUMN IF NOT EXISTS prerequisite_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL; -- Index für Prerequisites CREATE INDEX IF NOT EXISTS idx_exercise_variants_prerequisite ON exercise_variants(prerequisite_variant_id); -- ============================================================================ -- SEARCH VECTOR (Volltext-Suche) -- ============================================================================ -- Füge search_vector zu exercises hinzu ALTER TABLE exercises ADD COLUMN IF NOT EXISTS search_vector tsvector; -- Index für Volltext-Suche CREATE INDEX IF NOT EXISTS idx_exercises_search ON exercises USING gin(search_vector); -- Funktion für automatisches Update CREATE OR REPLACE FUNCTION update_exercises_search_vector() RETURNS trigger AS $func$ BEGIN NEW.search_vector := setweight(to_tsvector('german', COALESCE(NEW.title, '')), 'A') || setweight(to_tsvector('german', COALESCE(NEW.summary, '')), 'B') || setweight(to_tsvector('german', COALESCE(NEW.execution, '')), 'C') || setweight(to_tsvector('german', COALESCE(NEW.trainer_notes, '')), 'D'); RETURN NEW; END; $func$ LANGUAGE plpgsql; -- Trigger DROP TRIGGER IF EXISTS exercises_search_update ON exercises; CREATE TRIGGER exercises_search_update BEFORE INSERT OR UPDATE ON exercises FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector(); -- Initiales Befüllen (für existierende Zeilen) UPDATE exercises SET search_vector = ( setweight(to_tsvector('german', COALESCE(title, '')), 'A') || setweight(to_tsvector('german', COALESCE(summary, '')), 'B') || setweight(to_tsvector('german', COALESCE(execution, '')), 'C') || setweight(to_tsvector('german', COALESCE(trainer_notes, '')), 'D') ) WHERE search_vector IS NULL; -- ============================================================================ -- LEGACY COLUMN CLEANUP -- Deprecated Felder aus exercises (ersetzt durch M:N Tabellen in Migration 008+) -- ============================================================================ -- age_groups JSONB → ersetzt durch exercise_age_groups M:N (seit Migration 008) ALTER TABLE exercises DROP COLUMN IF EXISTS age_groups; -- focus_area VARCHAR → ersetzt durch exercise_focus_areas M:N (seit Migration 008) ALTER TABLE exercises DROP COLUMN IF EXISTS focus_area; -- secondary_areas JSONB → ersetzt durch exercise_focus_areas M:N ALTER TABLE exercises DROP COLUMN IF EXISTS secondary_areas; -- training_character VARCHAR → ersetzt durch exercise_training_characters M:N (seit Migration 012) ALTER TABLE exercises DROP COLUMN IF EXISTS training_character; -- ============================================================================ -- ADDITIONAL INDEXES (Performance) -- ============================================================================ -- Häufige Filter CREATE INDEX IF NOT EXISTS idx_exercises_visibility ON exercises(visibility); CREATE INDEX IF NOT EXISTS idx_exercises_status ON exercises(status); CREATE INDEX IF NOT EXISTS idx_exercises_created_at ON exercises(created_at DESC); -- M:N Relations (falls noch nicht vorhanden) CREATE INDEX IF NOT EXISTS idx_exercise_focus_areas_focus ON exercise_focus_areas(focus_area_id); CREATE INDEX IF NOT EXISTS idx_exercise_styles_style ON exercise_training_styles(training_style_id); CREATE INDEX IF NOT EXISTS idx_exercise_target_groups_group ON exercise_target_groups(target_group_id); CREATE INDEX IF NOT EXISTS idx_exercise_skills_skill ON exercise_skills(skill_id); RAISE NOTICE 'Migration 014 completed successfully'; END $$; ``` --- ## 3. Migration 015: Semantic Matching (Optional Phase 2) **Diese Migration ist optional für MVP Phase 1** ```sql -- Migration 015: Semantic Exercise Matching (OPTIONAL - Phase 2) -- Nutzt pgvector extension für Similarity-Matching -- Autor: Claude Code -- Datum: 2026-04-24 DO $$ BEGIN -- ============================================================================ -- SEMANTIC EMBEDDINGS (Optional) -- ============================================================================ -- Benötigt pgvector extension CREATE EXTENSION IF NOT EXISTS vector; -- Erweitere exercises Tabelle ALTER TABLE exercises ADD COLUMN IF NOT EXISTS embedding vector(1536); -- OpenAI ada-002 embedding size -- Index für Ähnlichkeitssuche CREATE INDEX IF NOT EXISTS idx_exercises_embedding ON exercises USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); -- Funktion für Similarity-Search CREATE OR REPLACE FUNCTION find_similar_exercises( query_embedding vector(1536), limit_count INT DEFAULT 10 ) RETURNS TABLE ( exercise_id INT, title VARCHAR, similarity FLOAT ) AS $func$ BEGIN RETURN QUERY SELECT id, title, 1 - (embedding <=> query_embedding) AS similarity FROM exercises WHERE embedding IS NOT NULL ORDER BY embedding <=> query_embedding LIMIT limit_count; END; $func$ LANGUAGE plpgsql; RAISE NOTICE 'Migration 015 completed successfully (Semantic Matching - OPTIONAL)'; END $$; ``` **Hinweis:** Embeddings werden über separaten Background-Job befüllt, nicht beim INSERT. --- ## 4. Migration 016: Saved Searches ```sql -- Migration 016: Saved Exercise Searches -- Autor: Claude Code -- Datum: 2026-04-24 DO $$ BEGIN -- ============================================================================ -- SAVED SEARCHES -- ============================================================================ CREATE TABLE IF NOT EXISTS saved_exercise_searches ( id SERIAL PRIMARY KEY, profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, filters JSONB NOT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- Index für User-Zugriff CREATE INDEX IF NOT EXISTS idx_saved_searches_profile ON saved_exercise_searches(profile_id); -- Trigger für updated_at CREATE OR REPLACE FUNCTION update_saved_searches_timestamp() RETURNS trigger AS $func$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $func$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS saved_searches_update ON saved_exercise_searches; CREATE TRIGGER saved_searches_update BEFORE UPDATE ON saved_exercise_searches FOR EACH ROW EXECUTE FUNCTION update_saved_searches_timestamp(); RAISE NOTICE 'Migration 016 completed successfully'; END $$; ``` --- ## 5. Migration 017: Exercise Blocks + Template Blocks ```sql -- Migration 017: Exercise Blocks + Template Blocks -- Autor: Claude Code -- Datum: 2026-04-24 -- Zweck: Gruppierung verschiedener Übungen in Blöcken (User-Anforderung #4) -- Series = Varianten-Progression (via exercise_variants, KEINE eigene Tabelle) -- Blocks = Verschiedene Übungen in Reihenfolge (DIESE Migration) DO $$ BEGIN -- ============================================================================ -- EXERCISE BLOCKS -- Eine Sammlung verschiedener Übungen in definierter Reihenfolge -- ============================================================================ CREATE TABLE IF NOT EXISTS exercise_blocks ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, description TEXT, goal TEXT, -- Template-Modus: Block mit Platzhaltern für flexible Planung is_template BOOLEAN DEFAULT false, -- Ownership & Sichtbarkeit club_id INT REFERENCES clubs(id) ON DELETE SET NULL, created_by INT REFERENCES profiles(id) ON DELETE SET NULL, visibility VARCHAR(20) DEFAULT 'private' CHECK (visibility IN ('private', 'club', 'official')), -- Timestamps created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- ============================================================================ -- EXERCISE BLOCK ITEMS -- Einzelne Positionen innerhalb eines Blocks -- ============================================================================ CREATE TABLE IF NOT EXISTS exercise_block_items ( id SERIAL PRIMARY KEY, block_id INT NOT NULL REFERENCES exercise_blocks(id) ON DELETE CASCADE, -- Konkrete Übung (NULL wenn is_placeholder = true) exercise_id INT REFERENCES exercises(id) ON DELETE RESTRICT, -- Optionale Variante (kann NULL sein → Haupt-Übung wird genutzt) variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL, -- Reihenfolge innerhalb des Blocks sequence_order INT NOT NULL, -- Template-Modus: Platzhalter statt konkreter Übung is_placeholder BOOLEAN DEFAULT false, -- Kriterien für Platzhalter-Auflösung (nur relevant wenn is_placeholder = true) -- Schema: {"focus_area_id": 1, "max_duration": 10, "skill_ids": [3, 7], "difficulty": "easier"} -- Alle Felder optional, werden als AND-Filter bei der Übungssuche genutzt placeholder_criteria JSONB, -- Platzhalter-Beschriftung (für UX im Template-Modus) placeholder_label VARCHAR(100), -- z.B. "Aufwärmübung Schlag", "Hauptübung Kumite" -- Zusätzliche Notizen für diese Position notes TEXT, -- Timestamps created_at TIMESTAMP DEFAULT NOW(), -- Constraints UNIQUE(block_id, sequence_order), -- Entweder exercise_id ODER is_placeholder=true CHECK ( (is_placeholder = false AND exercise_id IS NOT NULL) OR (is_placeholder = true AND exercise_id IS NULL) ) ); -- ============================================================================ -- INDEXES -- ============================================================================ CREATE INDEX IF NOT EXISTS idx_exercise_blocks_club ON exercise_blocks(club_id); CREATE INDEX IF NOT EXISTS idx_exercise_blocks_creator ON exercise_blocks(created_by); CREATE INDEX IF NOT EXISTS idx_exercise_blocks_visibility ON exercise_blocks(visibility); CREATE INDEX IF NOT EXISTS idx_exercise_blocks_template ON exercise_blocks(is_template) WHERE is_template = true; CREATE INDEX IF NOT EXISTS idx_exercise_block_items_block ON exercise_block_items(block_id); CREATE INDEX IF NOT EXISTS idx_exercise_block_items_exercise ON exercise_block_items(exercise_id); CREATE INDEX IF NOT EXISTS idx_exercise_block_items_placeholder ON exercise_block_items(is_placeholder) WHERE is_placeholder = true; -- ============================================================================ -- UPDATED_AT TRIGGER -- ============================================================================ DROP TRIGGER IF EXISTS exercise_blocks_update ON exercise_blocks; CREATE TRIGGER exercise_blocks_update BEFORE UPDATE ON exercise_blocks FOR EACH ROW EXECUTE FUNCTION update_timestamp(); RAISE NOTICE 'Migration 017 completed successfully (Exercise Blocks)'; END $$; ``` ### 5.0 Placeholder Criteria Schema Das `placeholder_criteria` JSONB-Feld in `exercise_block_items` erlaubt folgende Schlüssel (alle optional, werden als AND-Filter kombiniert): ```json { "focus_area_id": 1, // INT: Fokusbereich-ID "training_style_id": 2, // INT: Stil-ID "target_group_id": 5, // INT: Zielgruppen-ID "skill_ids": [3, 7], // INT[]: Mindestens eine dieser Fähigkeiten "max_duration": 15, // INT: Maximale Dauer in Minuten "min_duration": 5, // INT: Minimale Dauer in Minuten "difficulty": "easier", // "easier" | "same" | "harder" "visibility": "club" // "private" | "club" | "official" } ``` **Validierung:** Backend prüft, dass alle vorhandenen Schlüssel bekannte Felder sind und die Werte dem erwarteten Typ entsprechen. --- ## 6. Vollständige Tabellenstruktur (Final State) ### 5.1 exercises (Kern-Tabelle) ```sql CREATE TABLE exercises ( id SERIAL PRIMARY KEY, -- Basis-Info title VARCHAR(300) NOT NULL, summary TEXT, goal TEXT NOT NULL, execution TEXT NOT NULL, preparation TEXT, trainer_notes TEXT, -- Dauer & Gruppengröße duration_min INT, duration_max INT, group_size_min INT, group_size_max INT, -- JSONB-Felder equipment JSONB DEFAULT '[]'::jsonb, -- HINWEIS: age_groups werden via exercise_age_groups M:N Tabelle verwaltet (nicht JSONB) -- Suche search_vector tsvector, -- NEU in Migration 014 -- Semantic Matching (optional) embedding vector(1536), -- NEU in Migration 015 (optional) -- Sichtbarkeit & Status visibility VARCHAR(20) DEFAULT 'private' CHECK (visibility IN ('private', 'club', 'official')), status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'in_review', 'approved', 'archived')), -- Ownership created_by INT REFERENCES profiles(id) ON DELETE SET NULL, club_id INT REFERENCES clubs(id) ON DELETE SET NULL, -- Import-Tracking import_source VARCHAR(50), -- 'mediawiki', 'csv', etc. import_id VARCHAR(100), -- Original-ID aus Quellsystem -- Timestamps created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); ``` ### 5.2 exercise_variants ```sql CREATE TABLE exercise_variants ( id SERIAL PRIMARY KEY, exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, -- Variant-Details variant_name VARCHAR(200) NOT NULL, description TEXT, execution_changes TEXT, -- Dauer (Override) duration_min INT, duration_max INT, -- Equipment-Änderungen equipment_changes JSONB DEFAULT '[]'::jsonb, -- Schwierigkeit difficulty_adjustment VARCHAR(20) CHECK (difficulty_adjustment IN ('easier', 'same', 'harder')), -- Progression (NEU in Migration 014) progression_level INT DEFAULT 1 CHECK (progression_level BETWEEN 1 AND 10), sequence_order INT, prerequisite_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL, -- Timestamps created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); ``` ### 5.3 exercise_media ```sql CREATE TABLE exercise_media ( id SERIAL PRIMARY KEY, exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, -- Media-Type media_type VARCHAR(20) CHECK (media_type IN ('image', 'video', 'document', 'sketch')), -- Lokale Datei (exklusiv mit embed_url) file_path VARCHAR(500), file_size INT, mime_type VARCHAR(100), original_filename VARCHAR(300), -- Embed (exklusiv mit file_path) embed_url TEXT, embed_platform VARCHAR(50), -- 'youtube', 'vimeo', 'instagram', 'tiktok' -- Metadata title VARCHAR(200), description TEXT, sort_order INT DEFAULT 1, is_primary BOOLEAN DEFAULT FALSE, context VARCHAR(50) DEFAULT 'ablauf' CHECK (context IN ('ablauf', 'detail', 'trainer_hint')), -- Timestamps created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), -- Constraint: Entweder file_path ODER embed_url CHECK ( (file_path IS NOT NULL AND embed_url IS NULL) OR (file_path IS NULL AND embed_url IS NOT NULL) ) ); ``` ### 5.4 M:N Relation Tables **exercise_focus_areas:** ```sql CREATE TABLE exercise_focus_areas ( id SERIAL PRIMARY KEY, exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE RESTRICT, is_primary BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(exercise_id, focus_area_id) ); ``` **exercise_training_styles:** ```sql CREATE TABLE exercise_training_styles ( id SERIAL PRIMARY KEY, exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, training_style_id INT NOT NULL REFERENCES training_styles(id) ON DELETE RESTRICT, is_primary BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(exercise_id, training_style_id) ); ``` **exercise_target_groups:** ```sql CREATE TABLE exercise_target_groups ( id SERIAL PRIMARY KEY, exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE RESTRICT, is_primary BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(exercise_id, target_group_id) ); ``` **exercise_age_groups:** ```sql CREATE TABLE exercise_age_groups ( id SERIAL PRIMARY KEY, exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, age_group_name VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(exercise_id, age_group_name) ); ``` **exercise_skills:** ```sql CREATE TABLE exercise_skills ( id SERIAL PRIMARY KEY, exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, skill_id INT NOT NULL REFERENCES skills(id) ON DELETE RESTRICT, is_primary BOOLEAN DEFAULT FALSE, -- Kompetenzmodell: benannte Stufen (nicht numerisch 1-10) -- NULL = nicht definiert / nicht relevant required_level VARCHAR(20) CHECK (required_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')), target_level VARCHAR(20) CHECK (target_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')), -- Trainingsintensität: Wie stark wird diese Fähigkeit in der Übung trainiert intensity VARCHAR(10) CHECK (intensity IN ('niedrig', 'mittel', 'hoch')), -- KI-generierte Zuordnung (false = manuell bestätigt) ai_suggested BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(exercise_id, skill_id) ); ``` **Skill-Level-Definitionen (Kompetenzmodell):** | Stufe | Wert | Beschreibung | Beispiel Distanzgefühl | |-------|------|--------------|----------------------| | 1 | `einsteiger` | Erste Berührung mit der Fähigkeit, kein Vorwissen nötig | Versteht das Konzept Distanz | | 2 | `grundlagen` | Grundprinzipien bekannt, in einfachen Situationen anwendbar | Hält in ruhigen Übungen Distanz | | 3 | `aufbau` | Sicher in Standard-Situationen, braucht noch Korrektur | Distanzkontrolle in Partnerübungen | | 4 | `fortgeschritten` | Zuverlässig auch unter Druck, wenig Fehler | Stabile Distanz im Sparring | | 5 | `experte` | Automatisiert, intuitiv, auch in komplexen Situationen | Feines Distanzgefühl im Wettkampf | **Für Migration 014 ergänzen:** ```sql -- In Migration 014 nach den Variant-Columns ergänzen: -- Skill-Level auf VARCHAR umstellen (bestehende INT-Werte migrieren) ALTER TABLE exercise_skills ALTER COLUMN required_level TYPE VARCHAR(20) USING CASE required_level WHEN 0 THEN NULL WHEN 1 THEN 'einsteiger' WHEN 2 THEN 'grundlagen' WHEN 3 THEN 'aufbau' WHEN 4 THEN 'fortgeschritten' WHEN 5 THEN 'experte' ELSE NULL END, ALTER COLUMN target_level TYPE VARCHAR(20) USING CASE target_level WHEN 0 THEN NULL WHEN 1 THEN 'einsteiger' WHEN 2 THEN 'grundlagen' WHEN 3 THEN 'aufbau' WHEN 4 THEN 'fortgeschritten' WHEN 5 THEN 'experte' ELSE NULL END, ALTER COLUMN intensity TYPE VARCHAR(10) USING CASE WHEN intensity <= 3 THEN 'niedrig' WHEN intensity <= 7 THEN 'mittel' ELSE 'hoch' END; -- Neue Constraints hinzufügen ALTER TABLE exercise_skills ADD CONSTRAINT ck_required_level CHECK (required_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')), ADD CONSTRAINT ck_target_level CHECK (target_level IN ('einsteiger', 'grundlagen', 'aufbau', 'fortgeschritten', 'experte')), ADD CONSTRAINT ck_intensity CHECK (intensity IN ('niedrig', 'mittel', 'hoch')); -- KI-Tracking Feld ALTER TABLE exercises ADD COLUMN IF NOT EXISTS summary_ai_generated BOOLEAN DEFAULT false; ALTER TABLE exercise_skills ADD COLUMN IF NOT EXISTS ai_suggested BOOLEAN DEFAULT false; ``` **exercise_training_characters:** ```sql CREATE TABLE exercise_training_characters ( id SERIAL PRIMARY KEY, exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, training_character_id INT NOT NULL REFERENCES training_characters(id) ON DELETE RESTRICT, is_primary BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(exercise_id, training_character_id) ); ``` ### 6.5 Exercise Blocks (Migration 017) ```sql CREATE TABLE exercise_blocks ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, description TEXT, goal TEXT, is_template BOOLEAN DEFAULT false, club_id INT REFERENCES clubs(id) ON DELETE SET NULL, created_by INT REFERENCES profiles(id) ON DELETE SET NULL, visibility VARCHAR(20) DEFAULT 'private' CHECK (visibility IN ('private', 'club', 'official')), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE exercise_block_items ( id SERIAL PRIMARY KEY, block_id INT NOT NULL REFERENCES exercise_blocks(id) ON DELETE CASCADE, exercise_id INT REFERENCES exercises(id) ON DELETE RESTRICT, variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL, sequence_order INT NOT NULL, is_placeholder BOOLEAN DEFAULT false, placeholder_criteria JSONB, placeholder_label VARCHAR(100), notes TEXT, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(block_id, sequence_order), CHECK ( (is_placeholder = false AND exercise_id IS NOT NULL) OR (is_placeholder = true AND exercise_id IS NULL) ) ); ``` ### 6.6 Saved Searches (Migration 016) ```sql CREATE TABLE saved_exercise_searches ( id SERIAL PRIMARY KEY, profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, filters JSONB NOT NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); ``` --- ## 6. Indizes (Komplett-Übersicht) ```sql -- Volltext-Suche CREATE INDEX idx_exercises_search ON exercises USING gin(search_vector); -- Semantic Matching (optional) CREATE INDEX idx_exercises_embedding ON exercises USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); -- Häufige Filter CREATE INDEX idx_exercises_visibility ON exercises(visibility); CREATE INDEX idx_exercises_status ON exercises(status); CREATE INDEX idx_exercises_created_at ON exercises(created_at DESC); CREATE INDEX idx_exercises_club ON exercises(club_id); CREATE INDEX idx_exercises_creator ON exercises(created_by); -- M:N Relations CREATE INDEX idx_exercise_focus_areas_exercise ON exercise_focus_areas(exercise_id); CREATE INDEX idx_exercise_focus_areas_focus ON exercise_focus_areas(focus_area_id); CREATE INDEX idx_exercise_styles_exercise ON exercise_training_styles(exercise_id); CREATE INDEX idx_exercise_styles_style ON exercise_training_styles(training_style_id); CREATE INDEX idx_exercise_target_groups_exercise ON exercise_target_groups(exercise_id); CREATE INDEX idx_exercise_target_groups_group ON exercise_target_groups(target_group_id); CREATE INDEX idx_exercise_skills_exercise ON exercise_skills(exercise_id); CREATE INDEX idx_exercise_skills_skill ON exercise_skills(skill_id); CREATE INDEX idx_exercise_characters_exercise ON exercise_training_characters(exercise_id); CREATE INDEX idx_exercise_characters_character ON exercise_training_characters(training_character_id); -- Variants CREATE INDEX idx_exercise_variants_exercise ON exercise_variants(exercise_id); CREATE INDEX idx_exercise_variants_prerequisite ON exercise_variants(prerequisite_variant_id); -- Media CREATE INDEX idx_exercise_media_exercise ON exercise_media(exercise_id); CREATE INDEX idx_exercise_media_primary ON exercise_media(is_primary) WHERE is_primary = true; CREATE INDEX idx_exercise_media_context ON exercise_media(context); -- Saved Searches CREATE INDEX idx_saved_searches_profile ON saved_exercise_searches(profile_id); -- Exercise Blocks CREATE INDEX idx_exercise_blocks_club ON exercise_blocks(club_id); CREATE INDEX idx_exercise_blocks_creator ON exercise_blocks(created_by); CREATE INDEX idx_exercise_blocks_visibility ON exercise_blocks(visibility); CREATE INDEX idx_exercise_blocks_template ON exercise_blocks(is_template) WHERE is_template = true; CREATE INDEX idx_exercise_block_items_block ON exercise_block_items(block_id); CREATE INDEX idx_exercise_block_items_exercise ON exercise_block_items(exercise_id); CREATE INDEX idx_exercise_block_items_placeholder ON exercise_block_items(is_placeholder) WHERE is_placeholder = true; ``` --- ## 7. Constraints & Business Rules ### 7.1 Data Integrity **Exercises:** - `title` mindestens 3 Zeichen - `goal` mindestens 10 Zeichen - `execution` mindestens 10 Zeichen - `duration_min <= duration_max` - `group_size_min <= group_size_max` **Variants:** - `variant_name` mindestens 3 Zeichen - `prerequisite_variant_id` muss zum gleichen Exercise gehören **Media:** - Max. 50 MB per file (enforced in Backend) - Max. 10 media items per exercise (enforced in Backend) - Entweder `file_path` ODER `embed_url` (enforced in CHECK) ### 7.2 Cascading Delete **ON DELETE CASCADE:** - exercise → variants - exercise → media - exercise → M:N relations (focus_areas, styles, etc.) - profile → saved searches **ON DELETE RESTRICT:** - focus_area → exercise_focus_areas (verhindert Löschen genutzter Katalog-Einträge) - skill → exercise_skills - target_group → exercise_target_groups **ON DELETE SET NULL:** - profile (creator) → exercises - club → exercises - variant (prerequisite) → variants --- ## 8. Trigger-Funktionen ### 8.1 Search Vector Auto-Update ```sql CREATE OR REPLACE FUNCTION update_exercises_search_vector() RETURNS trigger AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('german', COALESCE(NEW.title, '')), 'A') || setweight(to_tsvector('german', COALESCE(NEW.summary, '')), 'B') || setweight(to_tsvector('german', COALESCE(NEW.execution, '')), 'C') || setweight(to_tsvector('german', COALESCE(NEW.trainer_notes, '')), 'D'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER exercises_search_update BEFORE INSERT OR UPDATE ON exercises FOR EACH ROW EXECUTE FUNCTION update_exercises_search_vector(); ``` ### 8.2 Updated_At Timestamp ```sql CREATE OR REPLACE FUNCTION update_timestamp() RETURNS trigger AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Trigger für alle relevanten Tabellen CREATE TRIGGER exercises_update_timestamp BEFORE UPDATE ON exercises FOR EACH ROW EXECUTE FUNCTION update_timestamp(); CREATE TRIGGER exercise_variants_update_timestamp BEFORE UPDATE ON exercise_variants FOR EACH ROW EXECUTE FUNCTION update_timestamp(); CREATE TRIGGER exercise_media_update_timestamp BEFORE UPDATE ON exercise_media FOR EACH ROW EXECUTE FUNCTION update_timestamp(); CREATE TRIGGER saved_searches_update_timestamp BEFORE UPDATE ON saved_exercise_searches FOR EACH ROW EXECUTE FUNCTION update_timestamp(); ``` --- ## 9. Migrations-Tracking **Tabelle: schema_migrations** ```sql CREATE TABLE IF NOT EXISTS schema_migrations ( migration_id VARCHAR(50) PRIMARY KEY, description TEXT, applied_at TIMESTAMP DEFAULT NOW() ); ``` **Nach jeder Migration:** ```sql INSERT INTO schema_migrations (migration_id, description) VALUES ('014', 'Variant Progression System + Search Vector + Legacy-Cleanup'), ('015', 'Semantic Exercise Matching (OPTIONAL)'), ('016', 'Saved Exercise Searches'), ('017', 'Exercise Blocks + Template Blocks'); ``` --- ## 10. Datenbank-Größenabschätzung **Annahmen:** - 5.000 Übungen - Durchschnittlich 2 Varianten pro Übung - Durchschnittlich 3 Media-Items pro Übung - Durchschnittlich 5 M:N Zuordnungen pro Übung **Geschätzte Zeilen:** ``` exercises: 5.000 exercise_variants: 10.000 exercise_media: 15.000 exercise_focus_areas: 10.000 exercise_training_styles: 10.000 exercise_target_groups: 10.000 exercise_skills: 25.000 exercise_blocks: 1.000 exercise_block_items: 5.000 ───────────────────────────────────── TOTAL: 101.000 Zeilen ``` **Speicherbedarf (ohne Media-Files):** - exercises Tabelle: ~50 MB (mit search_vector + embedding) - Alle M:N Tabellen: ~10 MB - Variants + Media: ~5 MB - **Total DB:** ~65 MB **Media-Files (lokal gespeichert):** - 15.000 Items × 5 MB Durchschnitt = ~75 GB --- ## 11. Performance-Benchmarks **Target Query Performance:** - List Exercises (ohne Filter): < 50ms - List Exercises (mit 3 Filtern): < 100ms - Volltext-Suche: < 150ms - Exercise Detail (enriched): < 80ms - Semantic Similarity: < 200ms (optional) **Optimierungen:** - GIN-Index für tsvector → 10x schnellere Suche - IVFFlat-Index für Embeddings → 50x schnellere Similarity-Search - Partial Index für `is_primary` Media → schnellere Primary-Lookups --- **Version:** 1.0 **Letzte Änderung:** 2026-04-24 **Status:** DRAFT - Awaiting Review