diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md index 4225c87..0e391f2 100644 --- a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md +++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md @@ -79,16 +79,18 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). ### Stufe E – Capabilities dokumentieren (ohne UI für Custom Roles) -- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `content.share_club`, `planning.edit_unit`, `org.manage_members`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen. +- **Verbindliche Spez v1:** `CAPABILITY_CATALOG.v1.md` — Capability-IDs, Account-Lifecycle, Rollen-Matrix, Endpoint-Mapping. +- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `exercises.ai.suggest`, `org.members.manage`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen (siehe Katalog §5–6). - Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen – keine zweite Philosophie. ### Stufe F – Community (eigenes Epic) - Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen. -### Zurückgestellt – Vereinsabo / Limits +### Zurückgestellt – Vereinsabo / Limits (Konzept liegt vor) -- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden. +- **Spez v1:** `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` — Feature-Registry (Mitai-v9c-Pattern), `club_plans`/`club_subscriptions`, Kontingente an `club_id`. +- Implementierung/Billing (Stripe) weiter zurückgestellt; Schema- und Enforcement-Hooks gemäß 4-Phasen-Rollout (Mitai-Vorbild) vorbereiten, sobald Stufe C/D stabil. --- @@ -117,6 +119,8 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`). ## 7. Referenzen +- **`CAPABILITY_CATALOG.v1.md`** – Rollen, Capabilities, CRUD-Mapping, `GET /api/me/entitlements`. +- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** – Vereinsabo, Feature-Limits, Mitai-Mapping, Ziel-Schema. - `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe. - `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln. - `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, `can_plan_in_club`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang. diff --git a/.claude/docs/technical/CAPABILITY_CATALOG.v1.md b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md new file mode 100644 index 0000000..02c2b5e --- /dev/null +++ b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md @@ -0,0 +1,331 @@ +# Capability-Katalog Shinkan v1 + +**Status:** Konzept (verbindliche Zieldefinition; M3 teilweise umgesetzt) +**Stand:** 2026-06-06 +**Bezüge:** `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` (Stufe E), `MULTI_TENANCY_RBAC_ARCHITECTURE.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`** (Produktentscheidungen) + +--- + +## 1. Zweck + +Dieses Dokument definiert **benannte Capabilities** (Wer darf welche **Funktion** ausführen?) — getrennt von: + +- **Governance** (Darf ich *dieses Objekt* lesen/ändern? → `visibility`, `club_id`, `created_by`) +- **Feature-Limits** (Wie viel darf der **Verein**? → `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`) + +Capabilities beantworten: *„Darf ein Trainer mit Rolle X die Funktion Y im Verein Z überhaupt nutzen?“* + +--- + +## 2. Namenskonvention + +``` +{domain}.{action}[.{qualifier}] +``` + +| Segment | Beispiele | +|---------|-----------| +| `domain` | `exercises`, `media`, `planning`, `org`, `platform` | +| `action` | `read`, `create`, `update`, `delete`, `manage`, `execute` | +| `qualifier` | `ai.suggest`, `join_request`, `inbox.review` | + +**CRUD-Mapping:** + +| Aktion | Capability-Suffix | Bedeutung | +|--------|-------------------|-----------| +| Lesen (Listen/Detail) | `.read` | Navigation + API-Lesen erlaubt | +| Anlegen | `.create` | POST/INSERT | +| Bearbeiten | `.update` | PUT/PATCH (eigenes + berechtigtes Fremdes) | +| Löschen | `.delete` | DELETE (strenger als update) | +| Verwalten | `.manage` | Org-Funktionen, Freigaben, Mitglieder | +| Ausführen (ohne Persistenz) | `.execute` | z. B. KI-Vorschau, Coach-Lauf | + +Objektbezogene Feinheiten (nur Ersteller, nur Vereinsadmin des Objekt-Vereins) bleiben in **Governance** — Capabilities sind das **Tür-Schloss** davor. + +--- + +## 3. Account-Lifecycle (Voraussetzung für Capabilities) + +| `account_state` | Bedingung | Typische Capabilities | +|-----------------|-----------|------------------------| +| `anonymous` | Keine Session | nur öffentliche Routen (`/login`, Rechtstexte, `clubs/public-directory`) | +| `unverified` | Session, `email_verified=false` | `account.resend_verification`, `account.logout` | +| `verified_pending_club` | Verifiziert, keine aktive `club_members` | `club.join_request`, `club.creation_request` (M7), `account.settings` — **kein** Lesezugriff auf Domänen-Inhalte (siehe Entscheidungs-Doc §1.1) | +| `active_member` | Mind. eine aktive Vereinsmitgliedschaft | Domänen-Capabilities gemäß Vereinsrolle | +| `platform_admin` | `role` ∈ `admin`, `superadmin` | `platform.*` zusätzlich | + +**Regel:** Domänen-Capabilities (`exercises.*`, `planning.*`, …) erfordern mindestens `active_member`, sofern nicht `platform_admin`. + +--- + +## 4. Rollen-Scopes + +### 4.1 Portal-Rollen (`profiles.role`) + +| Rolle | Scope | Kurz | +|-------|-------|------| +| `user` | Portal | Standard nach Registrierung (Zielbild; heute oft `trainer` Legacy) | +| `trainer` | Portal | Legacy — mittelfristig durch `user` + Vereinsrollen ersetzen | +| `admin` | Portal | Plattform-Admin (Vereine anlegen, erweiterte Ops) | +| `superadmin` | Portal | Vollzugriff Plattform + Superadmin-Werkzeuge | + +### 4.2 Vereinsrollen (`club_member_roles.role_code`) + +| Rolle | Fachlich | +|-------|----------| +| `club_admin` | Vereinsorganisation, Mitglieder, Struktur | +| `trainer` | Planung, Übungen, Durchführung | +| `content_editor` | Inhalte pflegen (Bibliothek) | +| `division_lead` | Spartenleitung (später division-scope) | + +Mehrfachrollen pro Mitgliedschaft sind möglich (OR-Verknüpfung der Capabilities). + +### 4.3 Mapping heutiger Helfer → Capabilities + +| Heutiger Code (`club_tenancy.py`) | Ziel-Capability-Cluster | +|-----------------------------------|-------------------------| +| `can_manage_club_org` | `org.structure.manage`, `org.members.manage`, `org.inbox.review` | +| `can_plan_in_club` | `planning.*`, `exercises.create/update`, `modules.*`, `framework.*` | +| `is_platform_admin` | `platform.*` (Bypass Mandant, Audit-Pflicht) | +| `is_superadmin` | `platform.superadmin.*` | + +--- + +## 5. Capability-Katalog (v1) + +Legende Spalten: + +- **Min. Account:** `verified_pending_club` | `active_member` | `platform_admin` +- **Vereinsrollen:** leer = alle aktiven Mitglieder; sonst mindestens eine Rolle +- **Feature-ID:** optionales Kontingent (siehe Club-Membership-Doc); leer = kein Limit +- **Governance:** zusätzliche Objektprüfung ja/nein + +### 5.1 Account & Onboarding + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI | +|---------------|--------------|---------------|------------|----------------| +| `account.settings.read` | `unverified` | — | — | `GET /profiles/me`, Einstellungen | +| `account.settings.update` | `unverified` | — | — | `PUT /profiles/{id}` (eigenes Profil) | +| `account.password.change` | `unverified` | — | — | `PUT /api/auth/pin` | +| `account.resend_verification` | `unverified` | — | — | `POST /api/auth/resend-verification` | +| `club.directory.read` | `verified_pending_club` | — | — | `GET /clubs/public-directory` | +| `club.join_request.create` | `verified_pending_club` | — | — | `POST /me/club-join-requests`, Registrierung mit `requested_club_id` | +| `club.join_request.withdraw` | `verified_pending_club` | — | — | `DELETE /me/club-join-requests/{id}` | +| `club.join_request.read_own` | `verified_pending_club` | — | — | `GET /me/club-join-requests` | + +### 5.2 Organisation (Verein) + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI | +|---------------|--------------|---------------|------------|----------------| +| `org.club.read` | `active_member` | * | — | `GET /clubs`, `GET /clubs/{id}` (eigene Vereine) | +| `org.club.create` | `platform_admin` | — | — | `POST /clubs` | +| `org.club.update` | `platform_admin` | `club_admin` | — | `PUT /clubs/{id}` | +| `org.club.delete` | `platform_admin` | — | — | `DELETE /clubs/{id}` | +| `org.structure.manage` | `active_member` | `club_admin` | `training_groups` | Sparten, Gruppen CRUD | +| `org.members.read` | `active_member` | `club_admin` | — | `GET /clubs/{id}/members` | +| `org.members.manage` | `active_member` | `club_admin` | `active_members` | POST/PUT/DELETE Mitglieder | +| `org.members.directory` | `active_member` | * | — | `GET /clubs/{id}/members/directory` (ohne E-Mail für Nicht-Admins) | +| `org.join_request.review` | `active_member` | `club_admin` | — | Join-Request accept/reject, Inbox | +| `org.inbox.read` | `active_member` | `club_admin` | — | Posteingang Join + Content-Reports | + +### 5.3 Übungen + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance | +|---------------|--------------|---------------|------------|------------| +| `exercises.read` | `active_member` | * | — | ja (visibility) | +| `exercises.create` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | `exercises` | — | +| `exercises.update` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | — | ja | +| `exercises.delete` | `active_member` | `club_admin` (+ Ersteller privat) | — | ja | +| `exercises.bulk_metadata` | `active_member` | `content_editor`, `club_admin` | — | ja | +| `exercises.ai.suggest` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | — | +| `exercises.ai.regenerate` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | ja (edit) | +| `exercises.media.read` | `active_member` | * | — | ja | +| `exercises.media.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja | +| `exercises.variants.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja | + +**Representative Endpoints:** `/api/exercises*`, `/api/exercises/ai/*`, Medien-Datei-Download. + +### 5.4 Medien-Bibliothek (Archiv) + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance | +|---------------|--------------|---------------|------------|------------| +| `media.library.read` | `active_member` | * | — | ja | +| `media.library.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja | +| `media.library.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja | +| `media.library.lifecycle` | `active_member` | `trainer`, `club_admin` | — | ja | +| `media.rights.declare` | `active_member` | `trainer`, `club_admin` | — | ja | +| `media.admin.rights_review` | `platform_admin` | — | — | Plattform-Admin Legacy-Review | + +### 5.5 Trainingsmodule & Rahmenprogramme + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance | +|---------------|--------------|---------------|------------|------------| +| `modules.read` | `active_member` | * | — | ja | +| `modules.create` | `active_member` | `trainer`, `content_editor`, `club_admin` | `training_programs` | — | +| `modules.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja | +| `modules.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja | +| `framework.read` | `active_member` | * | — | ja | +| `framework.create` | `active_member` | `trainer`, `club_admin` | `training_programs` | — | +| `framework.update` | `active_member` | `trainer`, `club_admin` | — | ja | +| `framework.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja | +| `plan_templates.read` | `active_member` | * | — | ja | +| `plan_templates.manage` | `active_member` | `trainer`, `club_admin` | — | ja | + +### 5.6 Progressionspfade + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance | +|---------------|--------------|---------------|------------|------------| +| `progression.read` | `active_member` | * | — | ja | +| `progression.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja | + +### 5.7 Planung & Durchführung + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance | +|---------------|--------------|---------------|------------|------------| +| `planning.calendar.read` | `active_member` | * | — | ja (Gruppe/Verein) | +| `planning.units.create` | `active_member` | `trainer`, `club_admin`, `division_lead` | `training_units` | ja | +| `planning.units.update` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja | +| `planning.units.delete` | `active_member` | `club_admin`, `trainer` (eigene) | — | ja | +| `planning.units.run` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja | +| `planning.coach.execute` | `active_member` | `trainer`, `club_admin` | — | ja | +| `planning.ai.suggest` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — | +| `planning.ai.progression_path` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — | + +### 5.8 Fähigkeiten & Scoring + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance | +|---------------|--------------|---------------|------------|------------| +| `skills.catalog.read` | `active_member` | * | — | globaler Katalog | +| `skills.discovery.read` | `active_member` | `trainer`, `content_editor` | — | — | +| `skill_profiles.read` | `active_member` | * | — | ja (Artefakt) | + +### 5.9 Governance & Meldungen + +| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | +|---------------|--------------|---------------|------------| +| `governance.content_report.create` | `active_member` | * | — | +| `governance.content_report.review` | `active_member` | `club_admin` | — | +| `governance.change_request.*` | `active_member` | `content_editor`, `club_admin` | — | + +### 5.10 Plattform (nur Portal-Admin / Superadmin) + +| Capability-ID | Min. Account | Portal-Rolle | Feature-ID | +|---------------|--------------|--------------|------------| +| `platform.admin.access` | `platform_admin` | `admin`, `superadmin` | — | +| `platform.users.manage` | `platform_admin` | `superadmin` | — | +| `platform.catalogs.manage` | `platform_admin` | `superadmin` | — | +| `platform.maturity_models.manage` | `platform_admin` | `superadmin` | — | +| `platform.wiki_import.execute` | `platform_admin` | `superadmin` | `wiki_import` | +| `platform.ai_prompts.manage` | `platform_admin` | `superadmin` | — | +| `platform.exercise_enrichment.execute` | `platform_admin` | `superadmin` | `ai_calls` | +| `platform.user_content.moderate` | `platform_admin` | `superadmin` | — | +| `platform.legal_documents.manage` | `platform_admin` | `superadmin` | — | +| `platform.media_storage.manage` | `platform_admin` | `superadmin` | — | +| `platform.club_creation.approve` | `platform_admin` | `superadmin` | — | + +*Geplant:* `club.creation_request.submit` → `verified_pending_club`; Freigabe über `platform.club_creation.approve`. + +--- + +## 6. Standard-Zuordnung Vereinsrolle → Capabilities (v1, fest) + +Diese Tabelle ist die **initiale** Grant-Matrix (`club_role_capability_grants`). Später durch Custom Roles ersetzbar — gleiche Capability-IDs. + +| Capability-Cluster | `club_admin` | `trainer` | `content_editor` | `division_lead` | +|--------------------|:------------:|:---------:|:----------------:|:---------------:| +| `org.structure.manage` | ✓ | — | — | ✓ (eigene Sparte, später) | +| `org.members.manage` | ✓ | — | — | — | +| `org.join_request.review` | ✓ | — | — | — | +| `exercises.read` | ✓ | ✓ | ✓ | ✓ | +| `exercises.create/update` | ✓ | ✓ | ✓ | ✓ | +| `exercises.delete` | ✓ | — | — | — | +| `exercises.ai.*` | ✓ | ✓ | ✓ | ✓ | +| `media.library.*` | ✓ | ✓ | ✓ | ✓ | +| `modules.*` / `framework.*` | ✓ | ✓ | ✓ | ✓ | +| `planning.*` | ✓ | ✓ | — | ✓ | +| `planning.coach.execute` | ✓ | ✓ | — | ✓ | +| `governance.content_report.review` | ✓ | — | — | — | + +--- + +## 7. API-Vertrag (Ziel) + +### 7.1 Effektive Rechte für Frontend + +``` +GET /api/me/entitlements?club_id={optional} +``` + +Antwort (Ausschnitt): + +```json +{ + "account_state": "active_member", + "portal_role": "user", + "club_id": 12, + "club_roles": ["trainer"], + "capabilities": { + "exercises.read": true, + "exercises.ai.suggest": true, + "org.members.manage": false + }, + "features": { + "ai_calls": { "allowed": true, "used": 4, "limit": 50, "remaining": 46, "reset_at": "2026-07-01T00:00:00Z" } + } +} +``` + +Frontend: Navigation und Buttons nur aus dieser Antwort — **keine** duplizierten Rollen-Checks in JSX (Ausnahme: rein kosmetische Labels). + +### 7.2 Backend-Enforcement + +Zentral (Zielmodul `authorization/capabilities.py` oder Erweiterung `club_tenancy.py`): + +```python +assert_capability(tenant, "exercises.ai.suggest", club_id=tenant.effective_club_id) +assert_club_feature(tenant, "ai_calls", club_id=tenant.effective_club_id) # siehe Club-Membership-Doc +# + bestehende Governance auf Objekt-Ebene +``` + +--- + +## 8. Implementierungsreihenfolge (Capabilities) + +| Phase | Inhalt | +|-------|--------| +| C0 | Account-Gates (`unverified`, `verified_pending_club`) — ohne Capability-DB | +| C1 | `capabilities` + `club_role_capability_grants` seed aus §5–6 | +| C2 | `GET /api/me/entitlements` + Frontend-Nav | +| C3 | Enforcement: KI-Endpoints, `exercises.create`, `planning.*` | +| C4 | Restliche Router schrittweise; Audit in `ACCESS_LAYER_ENDPOINT_AUDIT.md` | +| C5 | Custom Roles (optional) — gleiche IDs | + +--- + +## 9. Abgrenzung & Drift-Schutz + +1. **Neue Nutzerfunktion** → `register_capability()` in `rights_registrations/.py`, dann Endpoint mit `probe_capability`. Namenskonvention hier dokumentieren — **kein** Bulk-Seed in Migrationen. +2. **Kontingent** → `register_feature()` im selben Modul; Consume über `consume_club_feature_with_usage`. +3. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback. +4. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?). +5. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas). + +Siehe **`docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`** (Registry-first, ersetzt Katalog-first aus 079). + +--- + +## 10. Referenzen + +| Dokument | Inhalt | +|----------|--------| +| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Vereinsabo, Feature-Registry, Kontingente | +| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | TenantContext, Governance, Stufe E | +| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` | §4.6 Vereinsabo-Zielbild | +| `ACCESS_LAYER_ENDPOINT_AUDIT.md` | Endpoint-Pflege | +| Mitai `FEATURE_ENFORCEMENT.md` | 4-Phasen-Rollout-Vorbild | + +--- + +**Changelog** + +- 2026-06-06: v1 — Initial-Katalog aus Ist-Code (`club_tenancy`, Router-Inventar) + Ziel-Onboarding. diff --git a/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md new file mode 100644 index 0000000..5c897f2 --- /dev/null +++ b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md @@ -0,0 +1,478 @@ +# Vereins-Membership & Feature-System Shinkan v1 + +**Status:** Konzept + M1–M3 teilweise produktiv (siehe Entscheidungs-Doc §2) +**Stand:** 2026-06-06 +**Bezüge:** Schwesterprojekt Mitai (`v9c_subscription_system.sql`, `FEATURE_ENFORCEMENT.md`), `CAPABILITY_CATALOG.v1.md`, `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`** + +--- + +## 1. Zweck + +Shinkan verkauft und limitiert **nicht Einzelpersonen** (wie Mitai), sondern **Vereine**. Dieses Dokument definiert: + +- das **Feature-Registry**-Muster (limitierbare Funktionen), +- das **Vereins-Abo** (`club_plans`, `club_subscriptions`), +- **Kontingente** und Enforcement, +- die **Abbildung von Mitai** und **Vermeidung von Refactoring-Schulden**. + +Capabilities (Rollen: *darf ich die Funktion?*) → `CAPABILITY_CATALOG.v1.md`. + +--- + +## 2. Grundprinzip: Zwei Achsen + +```mermaid +flowchart TB + subgraph cap [Achse 1 — Capabilities] + CR[club_role_capability_grants] + PR[portal_role_capability_grants] + end + + subgraph feat [Achse 2 — Features / Kontingente] + FP[club_plans] + FPL[club_plan_limits] + FS[club_subscriptions] + FU[club_feature_usage] + end + + subgraph gov [Achse 3 — Governance] + GV[visibility / club_id / created_by] + end + + REQ[HTTP Request] --> ACCT[Account-Lifecycle] + ACCT --> cap + cap --> gov + gov --> feat + feat --> EXEC[Ausführung + increment] +``` + +| Frage | System | Subjekt | +|-------|--------|---------| +| Darf Trainer X KI nutzen? | Capability `exercises.ai.suggest` | `profile_id` + `club_role` | +| Wie viele KI-Aufrufe hat Verein Y? | Feature `ai_calls` | **`club_id`** | +| Darf ich diese Übung ändern? | Governance | Objekt + Mitgliedschaft | + +**Beide Achsen müssen erfüllt sein** (AND), außer dokumentierte Plattform-Ausnahmen. + +--- + +## 3. Mitai-Mapping (was übernehmen, was nicht) + +### 3.1 Übernehmen (Pattern) + +| Mitai (Person) | Shinkan (Verein) | Anmerkung | +|----------------|------------------|-----------| +| `features` (TEXT-PK, Registry) | `features` (`app='shinkan'`) | Gemeinsames Muster, ggf. später Jinkendo-weit | +| `tiers` | `club_plans` | Produktdefinition | +| `tier_limits` | `club_plan_limits` | Matrix Plan × Feature | +| `user_feature_restrictions` | `club_feature_overrides` | Admin-Override pro Verein | +| `user_feature_usage` | `club_feature_usage` | Verbrauch pro Verein | +| `access_grants` | `club_access_grants` | Trial, Promo, manuelle Freischaltung | +| `check_feature_access()` | `check_club_feature_access()` | Subjekt `club_id` | +| `increment_feature_usage()` | `increment_club_feature_usage()` | Nur bei INSERT / KI-Call | +| 4-Phasen-Rollout | identisch | Log → UI → Hard-Block | +| `GET /api/features/usage` | `GET /api/clubs/{id}/entitlements` | siehe Capability-Doc §7 | + +### 3.2 Nicht übernehmen + +| Mitai | Shinkan-Grund | +|-------|---------------| +| `profiles.tier` als Haupt-Abo | Verein zahlt, nicht Einzeltrainer | +| `subscriptions` (Shinkan `001`, INT-Features) | Ungenutzt, Schema-Drift | +| `get_effective_tier(profile_id)` für Shinkan-Limits | Ersetzen durch `get_effective_club_plan(club_id)` | +| Profil-zentrierte Enforcement-Hooks allein | Primär `club_id`; Profil nur für Attribution | + +### 3.3 Parallelität Jinkendo-Familie (später) + +`CENTRAL_SUBSCRIPTION_SYSTEM.md` (Mitai): zentrales Personen-Abo über Apps. + +**Zielbild ohne Refactoring:** + +``` +features.enforcement_subject ∈ { 'club', 'profile', 'portal' } + +effektives_limit(feature) = merge( + club_plan_limit(club_id, feature), # Shinkan-Hauptquelle + profile_grant_limit(profile_id, feature) # optional Jinkendo-Bonus +) +``` + +Merge-Regel (Vorschlag): **Maximum** der erlaubten Kontingente, boolean = OR. Details vor Stripe festlegen. + +--- + +## 4. Ist-Zustand Shinkan (Drift — zuerst bereinigen) + +| Artefakt | Problem | +|----------|---------| +| `backend/migrations/001_auth_membership.sql` | `features.id SERIAL`, `tier_limits.tier VARCHAR` | +| `backend/auth.py` `check_feature_access()` | Erwartet Mitai-v9c-Schema (`features.id TEXT`, `tier_id`, `limit_type`, …) | +| Kein Router | Ruft `check_feature_access` auf | +| `profiles.tier` | Existiert, ohne Shinkan-Enforcement | + +**Pflicht vor Phase 3 (Enforcement):** Migration `0XX_club_features_v1.sql` — v9c-kompatibles Feature-Schema + Vereins-Tabellen; alte `001`-Feature-Zeilen migrieren oder deprecaten. + +--- + +## 5. Ziel-Schema (v1) + +### 5.1 Feature-Registry (app-weit, Mitai-kompatibel) + +```sql +-- Konzept — Implementierung als nummerierte Migration +CREATE TABLE features ( + id TEXT PRIMARY KEY, -- z.B. 'ai_calls' + app TEXT NOT NULL DEFAULT 'shinkan', + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL, -- 'content'|'planning'|'ai'|'org'|'integration'|'platform' + limit_type TEXT NOT NULL DEFAULT 'count', -- 'count' | 'boolean' + reset_period TEXT NOT NULL DEFAULT 'never', -- 'never' | 'daily' | 'monthly' + default_limit INTEGER, -- NULL=∞, 0=aus + enforcement_subject TEXT NOT NULL DEFAULT 'club', -- 'club'|'profile'|'portal' + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 5.2 Vereins-Produkte & Abo + +```sql +CREATE TABLE club_plans ( + id TEXT PRIMARY KEY, -- 'free', 'verein_starter', 'verein_pro' + name TEXT NOT NULL, + description TEXT, + price_monthly_cents INTEGER, + price_yearly_cents INTEGER, + stripe_price_id_monthly TEXT, + stripe_price_id_yearly TEXT, + active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE club_subscriptions ( + id SERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + plan_id TEXT NOT NULL REFERENCES club_plans(id), + status TEXT NOT NULL DEFAULT 'active', -- active|trial|past_due|cancelled + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ends_at TIMESTAMPTZ, + trial_ends_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (club_id) -- ein aktiver Plan pro Verein (v1) +); + +CREATE TABLE club_plan_limits ( + id SERIAL PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + limit_value INTEGER, -- NULL=∞, 0=deaktiviert + UNIQUE (plan_id, feature_id) +); +``` + +### 5.3 Overrides, Grants, Verbrauch + +```sql +CREATE TABLE club_feature_overrides ( + id SERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + limit_value INTEGER NOT NULL, + reason TEXT, + set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (club_id, feature_id) +); + +CREATE TABLE club_access_grants ( + id SERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + plan_id TEXT REFERENCES club_plans(id), + feature_id TEXT REFERENCES features(id), -- optional Einzel-Feature + grant_limit INTEGER, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + reason TEXT, + created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL +); + +CREATE TABLE club_feature_usage ( + id SERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + usage_count INTEGER NOT NULL DEFAULT 0, + reset_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + UNIQUE (club_id, feature_id) +); + +-- Optional: Attribution / Fairness / Audit +CREATE TABLE club_feature_usage_events ( + id BIGSERIAL PRIMARY KEY, + club_id INT NOT NULL, + feature_id TEXT NOT NULL, + profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + action TEXT NOT NULL, -- 'ai_suggest', 'exercise_create', ... + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 5.4 Capabilities (Rollen — Kurzreferenz) + +Siehe `CAPABILITY_CATALOG.v1.md` für IDs. Tabellen: + +```sql +CREATE TABLE capabilities ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + domain TEXT NOT NULL, + min_account_state TEXT NOT NULL DEFAULT 'active_member', + linked_feature_id TEXT REFERENCES features(id), -- optional Kontingent + active BOOLEAN NOT NULL DEFAULT true +); + +CREATE TABLE club_role_capability_grants ( + role_code TEXT NOT NULL, -- club_admin, trainer, ... + capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE, + PRIMARY KEY (role_code, capability_id) +); + +CREATE TABLE portal_role_capability_grants ( + portal_role TEXT NOT NULL, -- admin, superadmin + capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE, + PRIMARY KEY (portal_role, capability_id) +); +``` + +--- + +## 6. Shinkan Feature-Katalog (Seed v1) + +Übernahme aus `001_auth_membership.sql` + Ist-Endpoints, angereichert: + +| feature_id | category | limit_type | reset_period | enforcement_subject | Default Free | Beschreibung | +|------------|----------|------------|--------------|---------------------|--------------|--------------| +| `exercises` | content | count | never | club | 100 | Anzahl Übungen im Verein (Bestand) | +| `exercise_media` | content | count | monthly | club | 20 | Medien-Uploads / Monat | +| `training_units` | planning | count | monthly | club | 40 | Geplante/durchgeführte Einheiten | +| `training_programs` | planning | count | never | club | 5 | Module + Rahmenprogramme (kombiniert v1) | +| `training_groups` | org | count | never | club | 10 | Trainingsgruppen | +| `active_members` | org | count | never | club | 25 | Aktive Mitglieder | +| `ai_calls` | ai | count | monthly | club | 0 | KI-Aufrufe (Suggest, Regenerate, Planung) | +| `ai_pipeline` | ai | boolean | never | club | 0 | Erweiterte KI-Pipelines (Batch, später) | +| `wiki_import` | integration | boolean | never | portal | 0 | MediaWiki-Import (Superadmin) | +| `data_export` | integration | boolean | never | club | 0 | Export-Funktionen (wenn eingeführt) | + +**Hinweis:** Free-Defaults sind Produktentscheidung — Tabelle dient Implementierung. + +### 6.1 Beispiel-Pläne (Seed) + +| plan_id | ai_calls/Monat | exercises | active_members | +|---------|----------------|-----------|----------------| +| `free` | 0 | 100 | 25 | +| `verein_starter` | 30 | 500 | 80 | +| `verein_pro` | 200 | NULL (∞) | NULL | +| `pilot` | 100 | NULL | NULL | + +Jeder Verein erhält bei Anlage durch Superadmin initial `club_subscriptions.plan_id = 'free'` (oder `pilot`). + +--- + +## 7. Auflösungslogik + +### 7.1 Effektiver Vereinsplan + +```python +def get_effective_club_plan(cur, club_id: int) -> str: + """ + 1. Aktiver club_access_grants mit plan_id (höchste Priorität, Zeitfenster) + 2. club_subscriptions.status == 'active' → plan_id + 3. Fallback 'free' + """ +``` + +### 7.2 Feature-Limit (analog Mitai `check_feature_access`) + +```python +def check_club_feature_access( + cur, + club_id: int, + feature_id: str, + *, + profile_id: int | None = None, # nur für Logging / optionale Profil-Boni später +) -> dict: + """ + Priorität: + 1. club_feature_overrides (club_id, feature_id) + 2. club_plan_limits für get_effective_club_plan(club_id) + 3. features.default_limit + + Auswertung: + - limit_type boolean: limit_value == 1 + - limit_type count: used < limit (club_feature_usage, reset beachten) + + Returns: { allowed, limit, used, remaining, reason, reset_at } + """ +``` + +### 7.3 Vollständige Request-Kette + +``` +1. require_auth +2. assert_account_state(min_state) # unverified / verified_pending_club / active_member +3. get_tenant_context +4. assert_capability(tenant, cap_id) # Rollen-Achse +5. assert_content_governance(...) # nur bei Objekt-Endpoints +6. check_club_feature_access(club_id, feature_id) +7. … Business-Logik … +8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage) + # Standard: zählen, JSON-Log phase=consume, feature_usage in Response +9. optional: club_feature_usage_events (profile_id, action) +``` + +**Response-Standard (alle Consume-Endpoints):** JSON-Feld `feature_usage` — Map `feature_id → { allowed, used, limit, remaining, reason, … }` wie `GET /me/entitlements`. Frontend: `request()` synchronisiert Entitlements automatisch (`featureUsageSync.js`); UI-Komponenten brauchen keinen Einzelcode. + +### 7.4 Wer zählt als Verbrauch? + +| Aktion | increment | Subjekt | +|--------|-----------|---------| +| `POST /exercises` (neu) | `exercises` | `club_id` des Objekts oder `effective_club_id` | +| Medien-Upload | `exercise_media` | Verein des Mediums | +| KI Suggest/Regenerate | `ai_calls` | `effective_club_id` | +| Mitglied hinzufügen | `active_members` | Ziel-`club_id` | +| Trainingsgruppe anlegen | `training_groups` | `club_id` | + +**Mitai-Regel:** Counter **nicht** bei UPDATE/DELETE erhöhen. + +--- + +## 8. API-Oberfläche + +### 8.1 Nutzer / Vereinsadmin + +``` +GET /api/clubs/{club_id}/entitlements +``` + +Kombiniert Capabilities + Feature-Kontingente (siehe `CAPABILITY_CATALOG.v1.md` §7.1). + +``` +GET /api/me/entitlements?club_id=12 +``` + +Bequemer Alias für aktiven Verein. + +### 8.2 Superadmin / Plattform + +| Endpoint | Zweck | +|----------|-------| +| `GET/PUT /api/admin/club-plans` | Plan-CRUD | +| `GET/PUT /api/admin/club-plan-limits` | Matrix | +| `GET/PUT /api/admin/clubs/{id}/subscription` | Verein-Abo | +| `GET/PUT /api/admin/clubs/{id}/feature-overrides` | Sonderkontingente | +| `POST /api/admin/clubs/{id}/access-grants` | Trial/Promo | + +Vorbild UI: Mitai `AdminTierLimitsPage.jsx`, `AdminUserRestrictionsPage.jsx` → Vereins-Kontext. + +### 8.3 Geplant: Vereinsgründung + +``` +POST /api/club-creation-requests # Nutzer (verified_pending_club) +GET /api/admin/club-creation-requests +POST /api/admin/club-creation-requests/{id}/approve # legt club + subscription an +``` + +--- + +## 9. Vier-Phasen-Rollout (aus Mitai) + +| Phase | Shinkan-Aktivität | Nutzer sichtbar? | +|-------|-------------------|------------------| +| **0** | Schema-Migration, Seed `features` + `club_plans`, Drift `001` bereinigen | Nein | +| **1** | Account-Gates + Capability-Grants (ohne Limits) | Onboarding-Hinweise | +| **2** | `check_club_feature_access` — **nur JSON-Log** (`feature_logger` analog Mitai) | Nein | +| **3** | `GET …/entitlements` + UsageBadge im UI | Ja (Kontingent-Anzeige) | +| **4** | HTTP 403 bei Limit + `increment` | Ja (Hard-Block) | + +**Reihenfolge innerhalb Phase 4:** zuerst `ai_calls`, dann `exercise_media`, dann Bestands-Limits (`exercises`, `active_members`). + +--- + +## 10. CI / Test-Isolation (Betrieb) + +Unabhängig vom Membership-System — **Pflicht** wegen Prod-Vorfälle (`access_layer_it_*@test.local`): + +| Regel | Umsetzung | +|-------|-----------| +| Integrationstests nie gegen Prod-DB | Eigene Test-DB oder Job-Postgres in Gitea | +| `ENVIRONMENT=production` + `ALLOW_INTEGRATION_TESTS` | Default `0`, Tests abbrechen | +| Test-Accounts | E-Mail `@test.local` oder `profiles.is_test_account` | +| Cleanup | Fixture-`finally` + Nightly-Job löscht Leichen | + +`.gitea/workflows/test.yml`: pytest-backend gegen Deploy-DB **ersetzen** durch isolierte DB (eigenes Epic, parallel zu Membership). + +--- + +## 11. Implementierungs-Roadmap (gesamt) + +| Schritt | Deliverable | Membership-relevant | +|---------|-------------|-------------------| +| M0 | CI-Isolation + Prod-Cleanup-Runbook | Nein | +| M1 | Migration Feature-Schema v9c + `club_plans`/`club_subscriptions` (leer nutzbar) | **Ja** | +| M2 | `check_club_feature_access` + Seed Pläne | **Ja** | +| M3 | Account-Lifecycle + Capability-Grants | Capabilities | +| M4 | `GET /me/entitlements` | **Ja** | +| M5 | Enforcement `ai_calls` (Phase 4) | **Ja** | +| M6 | Admin Plan-Matrix UI | **Ja** | +| M7 | `club_creation_requests` | Prozess | +| M8 | Stripe / Rechnung | Später | + +**Nach Produktentscheidungen 2026-06-06** (Details `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §4): + +| Phase | Paket | Priorität | +|-------|--------|-----------| +| A | Onboarding-Gates vollständig (`verified_pending_club`) | **Als Nächstes** | +| B | M7 Vereinsgründung beantragen | hoch | +| C | M5 Hard-Block `ai_calls` | danach | +| D | M6 Superadmin-UI | danach | +| E | Systemrolle `co_trainer` + Frontend-Entitlements | v1 Rollen | +| F | Trainer-Member-Budgets (v2) | später | + +--- + +## 12. Offene Produktentscheidungen + +Vor M6 festlegen: + +1. **Zählen `active_members`:** alle Mitglieder oder nur Rollen mit Planungsrecht? +2. **Soft-Limit vs. Hard-Stop:** Warnung bei 80 % oder sofort 403? +3. **Pilotverein:** eigener Plan `pilot` mit hohen Limits? +4. **KI-Fairness:** nur Vereinslimit oder zusätzlich Max pro Trainer/Monat? +5. **Offizielle Inhalte:** für `verified_pending_club` sichtbar oder gesperrt? → **entschieden: gesperrt** (`MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.1) +6. **Portal `admin` vs. `superadmin`:** Wer darf Vereine anlegen? (Ziel: nur `superadmin` für Freigabe) + +--- + +## 13. Referenzen + +| Pfad | Inhalt | +|------|--------| +| `c:/dev/mitai-jinkendo/backend/migrations/v9c_subscription_system.sql` | Mitai-Schema-Vorlage | +| `c:/dev/mitai-jinkendo/.claude/docs/architecture/FEATURE_ENFORCEMENT.md` | 4-Phasen-Modell | +| `c:/dev/mitai-jinkendo/.claude/docs/technical/MEMBERSHIP_SYSTEM.md` | Mitai-Hauptdoku | +| `c:/dev/mitai-jinkendo/.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` | Jinkendo-Familie später | +| `CAPABILITY_CATALOG.v1.md` | Rollen & Capabilities | +| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6 | Ursprüngliches Vereinsabo-Zielbild | +| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Stufe E/F | + +--- + +**Changelog** + +- 2026-06-06: v1 — Mitai-Mapping, Ziel-Schema, Feature-Seed, Auflösungslogik, Rollout. diff --git a/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md b/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md new file mode 100644 index 0000000..07f8d43 --- /dev/null +++ b/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md @@ -0,0 +1,243 @@ +# Membership, RBAC & Kontingente — Produktentscheidungen + +**Status:** Verbindlich (Zielbild & Roadmap-Priorisierung) +**Stand:** 2026-06-06 +**Bezüge:** `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` + +Dieses Dokument hält **getroffene Produktentscheidungen** fest (Session 2026-06-06) und ergänzt die v1-Konzept-Specs um Umsetzungsrichtung. Technischer Implementierungsstand: Abschnitt 2. + +--- + +## 1. Getroffene Entscheidungen + +### 1.1 Onboarding: `verified_pending_club` + +Nutzer **ohne aktive Vereinsmitgliedschaft** (E-Mail verifiziert) dürfen **nur**: + +| Erlaubt | Nicht erlaubt (Zielbild) | +|---------|---------------------------| +| Konto / Einstellungen | Übungen, Planung, KI, Medien | +| Vereinsverzeichnis lesen | Vereinsinterne Inhalte (`club`), private Fremdinhalte | +| **Beitrittsantrag** an bestehenden Verein | Vollzugriff auf Bibliothek / offizielle Inhalte (Lesen) — **bewusst gesperrt** bis Mitgliedschaft | +| **Vereinsgründung beantragen** (Prozess M7, Superadmin-Freigabe) | | + +**Kein** „Bibliothek durchstöbern“ für Bewerber — reduziert Datenexposition und vereinfacht UX („erst Verein, dann Arbeit“). + +Technischer Zustand: `account_state = verified_pending_club` (siehe `CAPABILITY_CATALOG.v1.md` §3). + +--- + +### 1.2 Rollenmodell: Risikoarm statt Big-Bang + +**Zielbild (langfristig):** + +- **Fest:** nur `superadmin` (Plattform) als nicht konfigurierbare Systemrolle. +- **Dynamisch konfigurierbar:** alle Vereinsrollen und deren Capability-Bundles (später `club_custom_roles`). +- Optional: `admin` (Plattform) als abgeschwächter Portal-Admin bleibt vorerst bestehen (Ist-Code). + +**Entscheidung v1 (risikoarm):** + +| Maßnahme | Jetzt | Später | +|----------|-------|--------| +| Alte Helfer (`can_plan_in_club`, `if (club_admin)` in JSX) | **Behalten** — weiter produktiv | Schrittweise durch `entitlements` ersetzen | +| Neue Endpoints / Features | Nur über **Capability-IDs** + Audit | — | +| Neue Vereinsrollen | Als **Systemrollen** ergänzen (z. B. `co_trainer`) | Custom Roles UI | +| `club_custom_roles` | **Nicht** in v1 | v2 Epic | + +**Begründung:** Backend und Frontend haben hunderte Verdrahtungen auf `trainer` / `club_admin` / Plattform-Rollen. Parallelbetrieb Capability-System + Legacy-Helfer ist sicherer als einmaliges Aufbrechen. + +**Co-Trainer (geplant als Systemrolle):** weniger Capabilities als `trainer` (z. B. kein `planning.*`, kein `exercises.create`) — Umsetzung nach Onboarding-Gates + Entitlements-Rollout, nicht vorher. + +--- + +### 1.3 Vereins-Kontingente (Membership-Pakete) + +**Jetzt:** Schema und Anzeige vorbereiten; **keine** detaillierte Paket-Logik (z. B. „3 Trainer + 10 Co-Trainer“) implementieren. + +| Vorbereitet (DB/Module) | Bewusst zurückgestellt | +|-------------------------|-------------------------| +| `features`, `club_plans`, `club_subscriptions` | Eigene Feature-IDs `trainer_seats` / `co_trainer_seats` | +| Bestands-Limits (`exercises`, `training_groups`, `ai_calls`, …) | Zählregel „nur planungsberechtigte Mitglieder“ vs. alle Mitglieder | +| `GET /me/entitlements` Feature-Teil | Stripe / Rechnung (M8) | + +**Prinzip:** Neue Kontingent-Typen = neue `features`-Zeile + Plan-Limits + optional Capability-`linked_feature_id` — ohne Schema-Bruch. + +--- + +### 1.4 Trainer-Budget innerhalb Vereins-Kontingent (v2) + +**Anforderung:** Vereins-KI-Kontingent liegt beim Verein; **Vereinsadmin** kann pro Trainer ein **Sub-Budget** vergeben (Fairness, „Kontingent-Fresser“). + +**Entscheidung:** + +- v1: nur **Vereins-Ebene** (`club_plan_limits`, `club_feature_usage`). +- v2: neue Tabellen (Skizze): + +```sql +-- Skizze — noch nicht migriert +club_member_feature_budgets (club_id, profile_id, feature_id, limit_value, …) +club_member_feature_usage (club_id, profile_id, feature_id, usage_count, reset_at, …) +``` + +**Prüf-Kette v2:** Capability → Mitglieds-Budget (falls gesetzt, `profile_id` aus Session) → Vereins-Kontingent. + +**Fairness-Modell (offen, Tendenz):** harte Sub-Budgets (Modell A) — Trainer darf sein Budget nicht überschreiten, auch wenn Verein noch Rest hat. + +**Roadmap:** Phase 5b / Meilenstein **M9** in `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` — Vereinsadmin-UI zur Verteilung, Entitlements mit persönlichem + Vereins-Rest, Auswertung je Person. + +--- + +### 1.5 Enforcement-Phasen (unverändert, bestätigt) + +| Phase | Verhalten | Nutzer sichtbar | +|-------|-----------|-----------------| +| 2 (M2/M3) | JSON-Log, kein Block | Nein (außer Logs) | +| 3 (M4) | `GET /me/entitlements` + Badge | Kontingent-Anzeige | +| 4 (M5+) | HTTP 403 + `increment` | Hard-Block | + +Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GATE_API_ENFORCE` (Default `1`, API-Middleware Phase A), `CAPABILITY_ENFORCE` / `CLUB_FEATURE_ENFORCE` (Default `0`). + +--- + +## 2. Implementierungsstand (Ist, Codebase) + +**DB-Schema:** `20260606083` · App **0.8.199** (`backend/version.py`) +**Roadmap (detailliert):** `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` + +### M1 — Feature-Schema v9c ✅ + +| Deliverable | Status | +|-------------|--------| +| Migration `078_club_features_and_plans.sql` | ✅ | +| Legacy `001` archiviert | ✅ | +| `club_plans`, `club_subscriptions`, Usage-Tabellen | ✅ | +| Seed Features + Pläne (`free`, …) | ✅ | +| `club_features.py`: `check_club_feature_access`, `get_effective_club_plan` | ✅ | +| Backfill Vereine → Plan `free` | ✅ | + +### M2 — Feature-Probe (Log only) ✅ + +| Deliverable | Status | +|-------------|--------| +| `club_feature_logger.py` → `club-feature-usage.log` | ✅ | +| `probe_club_feature_access()` | ✅ | +| Hooks: KI-Endpoints, `POST /exercises`, Medien-Upload, Planungs-KI | ✅ | +| Consume-Standard + `feature_usage` in Response (`ai_calls`) | ✅ | +| `CLUB_FEATURE_ENFORCE=0` (Default) | ✅ | + +### M3 — Account-Lifecycle + Capability-Grants ⚠️ teilweise + +| Deliverable | Status | Lücke | +|-------------|--------|-------| +| Migration `079_capabilities.sql` + Seed | ✅ | — | +| `account_lifecycle.py`, `resolve_account_state` | ✅ | — | +| `capabilities.py`, `check_capability`, `probe_capability` | ✅ | — | +| `TenantContext.account_state` | ✅ | — | +| `GET /profiles/me` → `account_state`, `club_roles` | ✅ | — | +| Account-Gates auf **Schreib-/KI-Endpoints** | ✅ | Lesepfade für Bewerber noch offen | +| `CAPABILITY_ENFORCE=0` (nur Log) | ✅ | — | +| Onboarding UX: nur Bewerbung/Gründung | ✅ | Phase A: API-Middleware + `/onboarding` + reduzierte Nav | +| `club_creation_requests` (M7) | ✅ Basis | Capabilities + Admin-Freigabe | +| Quota-Bypass via Capability-Grants (083) | ✅ | kein paralleles Exemption-Schema | +| Custom Roles / Co-Trainer | ❌ | bewusst v2 | +| Legacy-Helfer entfernt | ❌ | bewusst parallel | + +### M4 — Anzeige ✅ teilweise + +| Deliverable | Status | +|-------------|--------| +| `GET /api/me/entitlements` | ✅ | +| `EntitlementsContext`, `hasCapability()` | ✅ (UI nutzt noch kaum) | +| `FeatureUsageBadge` | ✅ nur KI im Übungsformular | +| `featureUsageSync` in `request()` | ✅ | + +### M5 — Hard-Block + vollständiger Verbrauch ⚠️ + +| Deliverable | Status | +|-------------|--------| +| `consume_club_feature_with_usage` Standard | ✅ `ai_calls` | +| `CLUB_FEATURE_ENFORCE=1` produktiv | ❌ Default 0 | +| Consume `exercises`, `exercise_media`, … | ❌ | + +### M6 — Admin UI Rollen & Rechte ⚠️ + +| Deliverable | Status | +|-------------|--------| +| `/admin/rights` Capability-Matrix (Portal + Verein) | ✅ | +| Klartext zuerst, Enforcement-Badge | ✅ 2026-06-07 | +| Kontingent-Bypass + Vereinspläne (Seed) | ✅ | +| Neue Pläne / Rollen anlegen (CRUD) | ❌ | + +### Bewusst zurückgestellt + +| ID | Inhalt | +|----|--------| +| M0 | CI-Isolation / Test-DB | +| M8 | Stripe | +| v2 | Trainer-Budgets, Custom Roles | + +--- + +## 3. Architektur-Zielbild (kompakt) + +``` +Request + → require_auth + → account_state (Gate) + → TenantContext + → assert_capability (Rolle / Funktion) + → check_club_feature_access (Vereins-Kontingent) + → [v2] member_feature_budget (Trainer-Budget) + → Governance (Objekt) +``` + +**Drei Achsen:** Account-Lifecycle · Capabilities · Features (Kontingente). Governance bleibt vierte Prüfung. + +--- + +## 4. Empfohlene Roadmap (nach Entscheidungen) + +| Phase | Paket | Warum zuerst | +|-------|--------|--------------| +| **A** | **Onboarding-Gates vollständig** | ✅ umgesetzt (API + Frontend `/onboarding`) | +| **B** | **M7 Vereinsgründung beantragen** | **Als Nächstes** — zweiter Pfad für `verified_pending_club` | +| **C** | **M5 Hard-Block `ai_calls`** | Free-Plan `0` wird real; Badge (M4) liefert Erklärung | +| **D** | **M6 voll** | Pläne-CRUD, Rollen-CRUD | ⚠️ Matrix da | +| **E** | Entitlements im Frontend (`hasCapability`) | Entscheidung 1.2 risikoarm | +| **F** | **M9 Kontingent-Verteilung** — Vereinsadmin vergibt Sub-Budgets pro Person (`profile_id`); Prüfung + Consume personenbezogen; UI Vereinsorga | Entscheidung 1.4, Roadmap Phase 5b | +| **G** | `co_trainer` + Custom Roles (v2) | Entscheidung 1.2 | + +M0 parallel, nicht blockierend. + +--- + +## 5. Offene Punkte (vor M6 / v2) + +1. Fairness Modell A/B/C für Trainer-Budget (Tendenz: A). +2. Ob `admin` (Portal) langfristig neben `superadmin` bleibt. +3. Ob offizielle Inhalte für Bewerber **nie** lesbar bleiben (aktuell: ja). + +--- + +## 6. Referenzen + +| Pfad | Inhalt | +|------|--------| +| `CAPABILITY_CATALOG.v1.md` | Capability-IDs, Account-States | +| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Feature-Registry, Kontingente | +| `backend/club_features.py` | Vereins-Features | +| `backend/capabilities.py` | Capability-Auflösung | +| `backend/account_lifecycle.py` | Account-Gates | + +## 7. Superadmin im Verein (FAQ) + +Siehe **`docs/working/RBAC_ENFORCEMENT_ROADMAP.md` §4**: Plattform-Admin (`admin`, `superadmin`) erhält **Capability-Bypass** für Vereins-Funktionen ohne `club_admin`-Mitgliedschaft. Mandant über aktiven Verein wählen; Kontingente via Bypass. Einzelne Legacy-Pfade (z. B. Löschen `visibility=club`) sind noch nicht vereinheitlicht — Ziel Phase 3. + +--- + +**Changelog** + +- 2026-06-06: Initial — Entscheidungen Onboarding, Rollen-Risiko, Kontingente, Trainer-Budget v2; Ist-Stand M1–M3; Roadmap A–F. +- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation. +- 2026-06-07: M4–M6 Ist-Stand, Roadmap-Verweis, Superadmin-FAQ; Admin-Matrix UX + Enforcement-Audit. +- 2026-06-08: Roadmap Phase 5b / M9 — Vereinsadmin-Kontingentverteilung pro Person; Enforce Dev verifiziert (0.8.202). diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md index 6ad6284..fb2629b 100644 --- a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md +++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md @@ -227,7 +227,9 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie ## 8. Verwandtes Dokument - **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** – verbindliche Umsetzungsstufen A–F, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt. +- **`CAPABILITY_CATALOG.v1.md`** – Rollen, Capability-IDs, Account-Lifecycle, Endpoint-Mapping. +- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** – Vereinsabo, Feature-Registry (Mitai-v9c-Pattern), Kontingente. --- -**Letzte Aktualisierung:** 2026-05-05 +**Letzte Aktualisierung:** 2026-06-06 diff --git a/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md b/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md index a950e32..942fc64 100644 --- a/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md +++ b/.claude/docs/working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md @@ -5,6 +5,8 @@ **Status:** Planungs-/Architektur-Arbeitspapier (keine Implementierungspflicht) **Ziel:** Für die **spätere** Planungs-KI bereits **Schnittstellen und Schichten** vorzeichnen, damit die **kleinere, starre** Übungs-KI nicht zur impliziten Vorlage für einen viel größeren Kopf wird — **ohne** jetzt eine Mitai-artige Workflow-Engine zu bauen. +**Update 2026-06-07:** Progressionsgraph startet **Phase F** (`planning_progression_roadmap.py`) — Roadmap-first, Workflow-lite. Siehe **`PLANNING_PROGRESSION_ROADMAP_SPEC.md`** und **`docs/architecture/PLANNING_KI_ROADMAP.md`**. Gruppenanalyse bleibt in der **Trainingsplanungs-Pipeline** (§3 S0–S4), nicht im Graphen. + **Bezüge:** `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/SKILL_SCORING_SPEC.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-003) · Schwesterprojekt Mitai: `c:/dev/mitai-jinkendo` (Referenz: `prompt_executor`, `placeholder_resolver`, `workflow_*` — **nicht** Pflicht-Port). --- @@ -107,6 +109,16 @@ So bleibt dieselbe fachliche Tiefe erreichbar ohne Kontext-Explosion. --- -## 9. Changelog +## 9. Progressionsgraph vs. Trainingsplanung (2026-06-07) +| Pipeline | Kontext | Orchestrator | +|----------|---------|--------------| +| **Progressionsgraph (F)** | Zieltext, N Steps, Semantic Brief | `planning_progression_roadmap.py` | +| **Trainingsplanung (G, später)** | Gruppe, Historie, Rahmen, Zeit | `planning_ai_steps` + ggf. Mitai Workflow | + +--- + +## 10. Changelog + +- **2026-06-07:** Verweis Phase F Roadmap-first; Abgrenzung Graphen/Planung. - **2026-05-22:** Erstfassung als Vorschau-Dokument für mehrstufige Planungs-KI. diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index e2d8e9c..bbce56c 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -172,7 +172,7 @@ score = w_ft * fulltext_rank Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: -- Gleiches `context_summary` an `suggestExerciseAi` anhängen (Felder `planning_context_json` o. ä. — noch offen) +- `planning_context` im Request-Body → `planning_context_json` in Übungs-Prompts (Migration **085**); Pfad-Builder + Picker ✅ **0.8.208** - Kurzbeschreibung optional leer (freier Vorschlag) oder aus Intent/Skizze --- @@ -193,7 +193,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: | **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** | | **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** | | **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** | -| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 | +| **D** | Neu-Anlage: `planning_context` an `suggestExerciseAi` (Migration **085**) | ✅ **0.8.208** | --- @@ -486,4 +486,30 @@ Nach Pfad-Bildung: --- -## 23. Backlog (offen) +## 23. Phase E3 (0.8.203) ✅ + +- Off-Topic aus Pfad entfernen; `gap_fill_offers` mit `goal_for_ai`; voller KI-Call im UI (kein Pre-Vorschlag) +- Migration **077** `suggested_new_exercises` im Pfad-QS-Prompt + +--- + +## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204+) 🔄 + +**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung. + +**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md` + +| Teil | Modul / API | +|------|-------------| +| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) | +| API | `include_roadmap_preview`, `include_llm_roadmap`, `roadmap_first` auf `progression-path-suggest` | +| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code | +| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) | + +**F3 (0.8.206):** `roadmap_first=true` (Default im UI) — Retrieval pro `stage_spec`/Major Step; `roadmap_unfilled` Gap-Angebote. Ohne Flag: retrieval-first wie bisher, Roadmap nur Preview. + +**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung. + +--- + +## 25. Backlog (offen) diff --git a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md new file mode 100644 index 0000000..6e0eadb --- /dev/null +++ b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md @@ -0,0 +1,198 @@ +# Planungs-KI — Progressions-Roadmap (Phase F) + +**Version:** 0.1 +**Datum:** 2026-06-07 +**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+) +**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse + +**Bezüge:** +`working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md` + +--- + +## 1. Entscheidung (2026-06-07) + +### 1.1 Problem + +Der Pfad-Builder (Phase C3/E) ist **retrieval-first**: Zieltext → N Übungen aus der Bibliothek → QS nachbessern. Das entspricht nicht der menschlichen Planung (Ziel → Roadmap → Stufenspezifikation → Übung). + +### 1.2 Festlegung + +| Thema | Entscheidung | +|--------|----------------| +| **Progressionsgraph** | **Roadmap-first** — Phasen A→B→C, dann Bibliothek (D), dann Feinausplanung (E) | +| **Gruppenanalyse** | **Nicht** in der Graphen-Pipeline — erst bei **Trainingsplanung** (Einheit/Rahmen) | +| **Mitai Workflow-Engine** | **Nicht** jetzt portieren — **Workflow-lite** (`PlanningProgressionPipeline`), später workflow-ready | +| **Ein Mega-Prompt** | **Verboten** — validierte Artefakte pro Phase | + +### 1.3 Abgrenzung Trainingsplanung + +``` +Progressionsgraph-Pipeline Trainingsplanungs-Pipeline (später) +───────────────────────── ─────────────────────────────────── +Ziel + N Major Steps Gruppe + Historie + Termin + Rahmen +Kein Gruppenkontext Kontext-Pack S0 (AI_PLANNING_KI_MULTISTAGE_FORECAST) +Curriculum / Technikpfad Session-Füllung / Reihenfolge / Zeiten +``` + +--- + +## 2. Menschliches Vorbild → Phasen + +| Mensch | Phase | Output-Artefakt | LLM | +|--------|-------|-----------------|-----| +| Startpunkt + Zielzustand | **A** Zielanalyse | `goal_analysis` | Optional (klein) | +| Zwischenziele, gewichten, auf N reduzieren | **B** Roadmap | `roadmap` (`micro_objectives[]`, `major_steps[N]`) | Ja | +| Belastung, Übungstyp, Lernziel je Stufe | **C** Stufenspezifikation | `stage_specs[]` | Teilweise | +| Bibliothek / Brücke | **D** Match | `step_matches[]` oder `gaps[]` | Nein (Retrieval) | +| Skizze + Feinplan | **E** Übungsentwurf | bestehend `suggestExerciseAi` | On-demand | + +**Phase B** = Kern: 8–12 `micro_objectives` → Konsolidierung → exakt `max_steps` `major_steps`. + +--- + +## 3. Pipeline-Orchestrator (Workflow-lite) + +Modul: **`backend/planning_progression_roadmap.py`** + +```python +ctx = ProgressionRoadmapContext(goal_query=..., max_steps=N, semantic_brief=...) +ctx = phase_a_goal_analysis(ctx) # deterministisch + optional LLM +ctx = phase_b_roadmap(ctx) # micro → major +ctx = phase_c_stage_specs(ctx) # je major_step +# Phase D/E: bestehende path_builder / retrieval / ai_fill — speisen von ctx.major_steps +``` + +Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-the-loop** (Roadmap-Review vor Übungs-Match). + +**Später:** jede Phase = Workflow-Knoten (Mitai-kompatibel), keine API-Änderung an Artefakten. + +--- + +## 4. JSON-Artefakte (Pydantic) + +### 4.1 `goal_analysis` (Phase A) + +```json +{ + "primary_topic": "Mae Geri", + "start_assumption": "Grundkenntnisse der Standführung, keine Perfektion", + "target_state": "Sicherer, präziser Mae Geri unter Belastung und in Anwendung", + "success_criteria": ["saubere Kammerhaltung", "Hüftführung", "Kime am Zielpunkt"], + "constraints": { "partner_required": false, "equipment": [] } +} +``` + +### 4.2 `roadmap` (Phase B) + +```json +{ + "micro_objectives": [ + { "id": "m1", "phase": "grundlage", "title": "Stellung und Kammerhaltung", "weight": 0.9, "depends_on": [] }, + { "id": "m2", "phase": "vertiefung", "title": "Hüft- und Kniekoordination", "weight": 0.85, "depends_on": ["m1"] } + ], + "major_steps": [ + { + "index": 0, + "phase": "grundlage", + "learning_goal": "Stabile Mae-Geri-Grundstellung", + "consolidates": ["m1"], + "rationale": "Einstieg ohne Perfektionsdruck" + } + ], + "consolidation_notes": ["Perfektion mit Anwendung zusammengeführt"] +} +``` + +### 4.3 `stage_spec` (Phase C, je Major Step) + +```json +{ + "major_step_index": 2, + "learning_goal": "…", + "load_profile": ["präzision", "koordination"], + "exercise_type": "kihon_einzel", + "success_criteria": ["…"], + "anti_patterns": ["reine Kraftübung ohne Technikbezug"] +} +``` + +--- + +## 5. API (schrittweise) + +### 5.1 Erweiterung `POST /api/planning/progression-path-suggest` + +| Feld (neu) | Default | Bedeutung | +|------------|---------|-----------| +| `roadmap_first` | `false` → später `true` | Roadmap-Pipeline vor Retrieval | +| `include_roadmap_preview` | `true` wenn `roadmap_first` | Artefakte A/B/C in Response | + +**Response (neu):** + +```json +{ + "progression_roadmap": { + "goal_analysis": { }, + "roadmap": { }, + "stage_specs": [ ], + "pipeline_phase": "roadmap_v1" + }, + "steps": [ ] +} +``` + +**Übergangsphase (0.8.204):** `include_roadmap_preview=true` liefert Roadmap **parallel** zum bestehenden retrieval-first Pfad — UI kann Roadmap reviewen, Schritte bleiben vorerst retrieval-basiert. + +**Zielphase (F2):** `roadmap_first=true` — Retrieval pro Major Step aus `stage_specs`, nicht mehr iterativ „beste nächste Übung“. + +### 5.2 Prompt-Slugs — nur in `ai_prompts`, nie im Code + +**Regel:** Prompt-**Texte** leben ausschließlich in der Tabelle `ai_prompts` (Superadmin bearbeitbar, Vorschau, `openrouter_model` pro Zeile). Python referenziert nur **Slugs** (`PROMPT_SLUG_*` in `planning_progression_roadmap.py`). Kein verstecktes Hardcoding von Templates. + +| Slug | Phase | Migration | +|------|-------|-----------| +| `planning_progression_goal_analysis` | A | **078** | +| `planning_progression_roadmap` | B | **078** | +| `planning_progression_stage_spec` | C | **079** | + +**API:** `include_llm_roadmap` (Default `true`) — lädt Prompts via `load_and_render_ai_prompt`. Bei Fehler/kein OpenRouter: **deterministischer Fallback** (kein stilles Versagen). + +**Response:** `prompt_slugs` (genutzte Slugs), `prompt_slug_catalog` (Referenz), `llm_*_applied` Flags. + +**Admin:** Templates unter Kategorie `training` pflegen — siehe `AI_PROMPT_SYSTEM_SPEC.md`. + +--- + +## 6. UI-Roadmap + +1. **F1:** Roadmap-Box unter Ziel-Eingabe (Major Steps als Karten, editierbar) — vor Übungsliste +2. **F2:** Match-Ergebnis pro Major Step (Bibliothek / Lücke / KI anlegen) +3. **F3:** `roadmap_first` als Default im Graph-Builder + +--- + +## 7. Was bewusst nicht in Phase F + +- Gruppen-Historie, Belastungssteuerung der Gruppe +- Mitai `workflow_engine` Port +- Vollautomatisches Speichern ohne Trainer-Review + +--- + +## 8. Implementierungsstände + +| ID | Inhalt | Status | +|----|--------|--------| +| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | 🔄 0.8.204 | +| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 | +| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 | +| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 | +| **F4** | UI Roadmap-Review | ✅ 0.8.207 | +| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 | + +--- + +## 9. Changelog + +- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite. diff --git a/.env.example b/.env.example index 66c24e7..ad3344b 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD OPENROUTER_API_KEY=your_api_key_here OPENROUTER_MODEL=anthropic/claude-sonnet-4 + +# Vereins-Kontingente hart blockieren (KI-Kosten!). Nur 1, true oder yes aktivieren. +# Nach Änderung: docker compose -f docker-compose.dev-env.yml up -d backend +CLUB_FEATURE_ENFORCE=1 # Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model # ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“). diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index ad2ae3d..d97832c 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -18,6 +18,11 @@ jobs: docker compose -f docker-compose.dev-env.yml build --no-cache docker compose -f docker-compose.dev-env.yml up -d sleep 5 - curl -sf http://localhost:8098/api/version && echo "✓ DEV API healthy" + if ! curl -sf http://localhost:8098/api/version; then + echo "✗ DEV API nicht erreichbar — Backend-Logs (Migration/Startup):" + docker compose -f docker-compose.dev-env.yml logs backend --tail 120 || true + exit 1 + fi + echo "✓ DEV API healthy" curl -sf http://localhost:3098/api/version && echo "✓ DEV über Frontend-Nginx (wie Browser) healthy" echo "=== Shinkan DEV Deploy complete ===" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index b340ef3..5cf8f19 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -1,10 +1,13 @@ name: Test Suite +# develop: push/PR → Tests gegen Dev (parallel oder vor Deploy Development). +# main: kein push/PR-Trigger — vermeidet doppelten Dev-Lauf beim Merge develop→main; +# Prod-Tests nur via workflow_run nach erfolgreichem Deploy Production. on: push: - branches: [main, develop] + branches: [develop] pull_request: - branches: [main, develop] + branches: [develop] workflow_run: workflows: ["Deploy Development", "Deploy Production"] types: [completed] @@ -17,8 +20,10 @@ jobs: steps: - name: Backend pytest im deployten Container run: | + set -e EVENT_NAME="${{ github.event_name }}" REF_NAME="${{ github.ref_name }}" + BASE_REF="${{ github.base_ref }}" RUN_WORKFLOW="${{ github.event.workflow_run.name }}" APP_DIR="/home/lars/docker/shinkan" COMPOSE_FILE="docker-compose.yml" @@ -28,12 +33,27 @@ jobs: APP_DIR="/home/lars/docker/shinkan-dev" COMPOSE_FILE="docker-compose.dev-env.yml" fi - elif [ "$REF_NAME" = "develop" ]; then + elif [ "$REF_NAME" = "develop" ] || [ "$BASE_REF" = "develop" ]; then APP_DIR="/home/lars/docker/shinkan-dev" COMPOSE_FILE="docker-compose.dev-env.yml" fi cd "$APP_DIR" + echo "Warte auf stabilen backend-Container …" + for i in $(seq 1 60); do + if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then + echo "Backend bereit (Versuch $i)" + break + fi + if [ "$i" -eq 60 ]; then + echo "Timeout: backend-Container nicht bereit" + docker compose -f "$COMPOSE_FILE" ps || true + docker compose -f "$COMPOSE_FILE" logs backend --tail 80 || true + exit 1 + fi + sleep 5 + done + docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc " pip install -r /app/requirements-dev.txt && cd /app && diff --git a/CLAUDE.md b/CLAUDE.md index ebea17c..f8d36f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ > | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** | > | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** | > | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` | +> | Planungs-KI Progressions-Roadmap (Phase F) | **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · **`docs/architecture/PLANNING_KI_ROADMAP.md`** | ## Projekt-Übersicht diff --git a/backend/account_lifecycle.py b/backend/account_lifecycle.py new file mode 100644 index 0000000..00bfcf2 --- /dev/null +++ b/backend/account_lifecycle.py @@ -0,0 +1,77 @@ +""" +Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0). + +Zustände: unverified → verified_pending_club → active_member; platform_admin separat. +""" +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Optional + +from fastapi import HTTPException + +from club_tenancy import is_platform_admin + +if TYPE_CHECKING: + from tenant_context import TenantContext + +_ACCOUNT_STATE_RANK = { + "unverified": 1, + "verified_pending_club": 2, + "active_member": 3, + "platform_admin": 4, +} + + +def resolve_account_state( + *, + email_verified: bool, + global_role: str, + has_active_membership: bool, +) -> str: + """Ermittelt account_state für ein Profil.""" + if is_platform_admin(global_role): + return "platform_admin" + if not email_verified: + return "unverified" + if not has_active_membership: + return "verified_pending_club" + return "active_member" + + +def account_state_satisfies(current: str, required: str) -> bool: + """True wenn current mindestens required ist.""" + cur = _ACCOUNT_STATE_RANK.get(current, 0) + req = _ACCOUNT_STATE_RANK.get(required, 99) + if current == "platform_admin": + return True + return cur >= req + + +def account_gate_enforcement_enabled() -> bool: + """Account-Gates aktiv (Default an — nur wenige Endpoints in M3).""" + return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1" + + +def assert_min_account_state( + tenant: "TenantContext", + min_state: str, + *, + endpoint: Optional[str] = None, +) -> None: + """ + Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default). + """ + current = getattr(tenant, "account_state", "active_member") + ok = account_state_satisfies(current, min_state) + if ok: + return + if not account_gate_enforcement_enabled(): + return + detail = ( + f"Account-Status „{current}“ reicht nicht für diese Aktion " + f"(erforderlich: {min_state})." + ) + if endpoint: + detail = f"{detail} ({endpoint})" + raise HTTPException(status_code=403, detail=detail) diff --git a/backend/account_onboarding_gate.py b/backend/account_onboarding_gate.py new file mode 100644 index 0000000..b643b10 --- /dev/null +++ b/backend/account_onboarding_gate.py @@ -0,0 +1,178 @@ +""" +API-Gates für Onboarding (Phase A — MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1). + +Blockiert Domänen-APIs für unverified / verified_pending_club vor dem Router. +""" +from __future__ import annotations + +import os +import re +from typing import Optional, Tuple + +from account_lifecycle import resolve_account_state +from club_tenancy import memberships_with_roles + +# Öffentlich ohne Session +PUBLIC_API_PREFIXES = ( + "/api/auth/login", + "/api/auth/register", + "/api/auth/forgot-password", + "/api/auth/reset-password", + "/api/auth/verify/", + "/api/legal-documents/", + "/api/clubs/public-directory", + "/api/version", + "/api/health/", + "/health", +) + +# Mit Session, unabhängig vom account_state (Logout, Profil lesen, …) +AUTH_INFRA_PREFIXES = ( + "/api/auth/logout", + "/api/auth/me", + "/api/auth/status", + "/api/auth/pin", + "/api/auth/resend-verification", + "/api/profiles/me", + "/api/me/entitlements", +) + +# Zusätzlich für verified_pending_club (Verein bewerben) +PENDING_CLUB_PREFIXES = ( + "/api/me/club-join-requests", + "/api/me/club-creation-requests", +) + +_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$") + + +def api_onboarding_gate_enabled() -> bool: + """Produktions-Gate aktiv (ACCOUNT_GATE_API_ENFORCE=0 zum Abschalten).""" + return os.getenv("ACCOUNT_GATE_API_ENFORCE", "1").strip() == "1" + + +def _middleware_db_lookup_enabled() -> bool: + """ + Middleware-Session-Lookup nur mit echter DB (nicht in pytest TestClient ohne Postgres). + """ + if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): + return False + if os.getenv("PYTEST_CURRENT_TEST"): + return False + return True + + +def normalize_api_path(path: str) -> str: + p = (path or "").split("?", 1)[0].strip() + if not p.startswith("/"): + p = "/" + p + if len(p) > 1 and p.endswith("/"): + p = p[:-1] + return p + + +def is_public_api_path(path: str) -> bool: + p = normalize_api_path(path) + return any(p == pref or p.startswith(pref) for pref in PUBLIC_API_PREFIXES) + + +def _path_allowed_for_state(path: str, method: str, account_state: str, profile_id: int) -> bool: + p = normalize_api_path(path) + m = (method or "GET").upper() + + for pref in AUTH_INFRA_PREFIXES: + if p == pref or p.startswith(pref + "/"): + return True + + match = _PROFILE_MUTATION_RE.match(p) + if match and m in ("PUT", "PATCH") and int(match.group(1)) == int(profile_id): + return True + + if account_state == "unverified": + return False + + if account_state == "verified_pending_club": + for pref in PENDING_CLUB_PREFIXES: + if p == pref or p.startswith(pref + "/"): + return True + return False + + return True + + +def resolve_account_state_for_token(cur, session_row: dict) -> str: + profile_id = int(session_row["profile_id"]) + role = (session_row.get("role") or "").lower() + cur.execute( + "SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s", + (profile_id,), + ) + prof = cur.fetchone() + email_verified = bool(prof.get("email_verified")) if prof else False + memberships = memberships_with_roles(cur, profile_id, active_only=True) + has_active = len(memberships) > 0 + return resolve_account_state( + email_verified=email_verified, + global_role=role, + has_active_membership=has_active, + ) + + +def check_api_onboarding_gate( + *, + path: str, + method: str, + profile_id: int, + account_state: str, +) -> Tuple[bool, Optional[str]]: + """ + Returns (allowed, reason). + active_member / platform_admin → immer erlaubt (Domain). + """ + if not api_onboarding_gate_enabled(): + return True, None + + if account_state in ("active_member", "platform_admin"): + return True, None + + if _path_allowed_for_state(path, method, account_state, profile_id): + return True, None + + return False, f"account_state_{account_state}" + + +def evaluate_request_gate(token: Optional[str], path: str, method: str) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Vollständige Prüfung inkl. Session-Lookup. + Returns: allowed, reason, account_state (für Logging) + """ + if not api_onboarding_gate_enabled() or not _middleware_db_lookup_enabled(): + return True, None, None + + p = normalize_api_path(path) + if not p.startswith("/api/"): + return True, None, None + if is_public_api_path(p): + return True, None, None + if not token: + return True, None, None + + from auth import get_session + from db import get_db, get_cursor + + session = get_session(token) + if not session: + return True, None, None + + profile_id = int(session["profile_id"]) + with get_db() as conn: + cur = get_cursor(conn) + account_state = resolve_account_state_for_token(cur, session) + + allowed, reason = check_api_onboarding_gate( + path=p, + method=method, + profile_id=profile_id, + account_state=account_state, + ) + return allowed, reason, account_state diff --git a/backend/ai_prompt_context.py b/backend/ai_prompt_context.py index bd441b4..d3b34b6 100644 --- a/backend/ai_prompt_context.py +++ b/backend/ai_prompt_context.py @@ -5,7 +5,7 @@ Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / ex """ from __future__ import annotations -from typing import List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple from pydantic import BaseModel, Field @@ -31,6 +31,7 @@ class ExerciseFormAiPromptContext(BaseModel): trainer_notes: Optional[str] = None focus_hint: Optional[str] = None focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None + planning_context: Optional[Dict[str, Any]] = None def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]: if not self.focus_areas_context: @@ -57,6 +58,7 @@ class ExerciseFormAiPromptContext(BaseModel): trainer_notes: Optional[str] = None, focus_area_hint: Optional[str] = None, focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None, + planning_context: Optional[Dict[str, Any]] = None, ) -> ExerciseFormAiPromptContext: """Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint).""" hint = (focus_area_hint or "").strip() or None @@ -68,6 +70,7 @@ class ExerciseFormAiPromptContext(BaseModel): trainer_notes=trainer_notes, focus_hint=hint, focus_areas_context=list(focus_areas_context) if focus_areas_context else None, + planning_context=dict(planning_context) if planning_context else None, ) @classmethod diff --git a/backend/ai_prompt_job.py b/backend/ai_prompt_job.py index 38074ba..f0595ca 100644 --- a/backend/ai_prompt_job.py +++ b/backend/ai_prompt_job.py @@ -23,6 +23,7 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon focus_areas_context=ctx.focus_area_tuples(), preparation=ctx.preparation, trainer_notes=ctx.trainer_notes, + planning_context=ctx.planning_context, ) diff --git a/backend/auth.py b/backend/auth.py index 980e24c..7e3b18e 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -170,6 +170,10 @@ def get_effective_tier(profile_id: str, conn=None) -> str: def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict: """ + DEPRECATED für Shinkan: Mitai-v9c-Profil-Limits — Schema 001 ist archiviert (Migration 078). + + Für Vereins-Kontingente: club_features.check_club_feature_access(club_id, feature_id). + Check if a profile has access to a feature. Access hierarchy: @@ -315,6 +319,8 @@ def _check_impl(profile_id: str, feature_id: str, conn) -> dict: def increment_feature_usage(profile_id: str, feature_id: str) -> None: """ + DEPRECATED für Shinkan — siehe club_features.increment_club_feature_usage. + Increment usage counter for a feature. Creates usage record if it doesn't exist, with reset_at based on diff --git a/backend/capabilities.py b/backend/capabilities.py new file mode 100644 index 0000000..4e5d799 --- /dev/null +++ b/backend/capabilities.py @@ -0,0 +1,285 @@ +""" +Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1). + +Phase 2: probe_capability — JSON-Log, kein Block (CAPABILITY_ENFORCE=0). +Phase 3+: CAPABILITY_ENFORCE=1 — HTTP 403 bei fehlender Capability. +""" +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from fastapi import HTTPException + +from account_lifecycle import account_state_satisfies +from club_tenancy import is_platform_admin +from db import get_db, get_cursor + +if TYPE_CHECKING: + from tenant_context import TenantContext + + +def capability_enforcement_enabled() -> bool: + v = os.getenv("CAPABILITY_ENFORCE", "0").strip().lower() + return v in ("1", "true", "yes") + + +def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]: + if club_id is None: + return [] + for m in tenant.memberships or []: + if int(m.get("id") or 0) == int(club_id): + roles = m.get("roles") or [] + if hasattr(roles, "tolist"): + roles = roles.tolist() + return list(roles) + return [] + + +def check_capability( + cur, + tenant: "TenantContext", + capability_id: str, + *, + club_id: Optional[int] = None, +) -> Dict[str, Any]: + """ + Prüft eine Capability für Tenant + optionalen Vereinskontext. + + Returns: allowed, reason, account_state, club_roles, linked_feature_id + """ + account_state = getattr(tenant, "account_state", "active_member") + eff_club = club_id if club_id is not None else tenant.effective_club_id + club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else [] + + cur.execute( + """ + SELECT id, min_account_state, linked_feature_id, active, domain + FROM capabilities + WHERE id = %s + """, + (capability_id,), + ) + cap = cur.fetchone() + if not cap or not cap.get("active"): + return { + "allowed": False, + "reason": "capability_not_found", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": None, + } + + min_state = cap.get("min_account_state") or "active_member" + if not account_state_satisfies(account_state, min_state): + return { + "allowed": False, + "reason": "account_state_insufficient", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + + domain = (cap.get("domain") or "").strip().lower() + + # Kontingent-Bypass (konfigurierbar per portal_role / profile grants, ohne Plattform-Admin-Pflicht) + if domain == "quota_bypass": + role_lc = (tenant.global_role or "").lower() + cur.execute( + """ + SELECT 1 FROM portal_role_capability_grants + WHERE portal_role = %s AND capability_id = %s + LIMIT 1 + """, + (role_lc, capability_id), + ) + if cur.fetchone(): + return { + "allowed": True, + "reason": "quota_bypass_portal_grant", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + cur.execute( + """ + SELECT 1 FROM profile_capability_grants + WHERE profile_id = %s AND capability_id = %s + LIMIT 1 + """, + (tenant.profile_id, capability_id), + ) + if cur.fetchone(): + return { + "allowed": True, + "reason": "quota_bypass_profile_grant", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + return { + "allowed": False, + "reason": "quota_bypass_denied", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + + # Plattform-Capabilities + if domain == "platform" or capability_id.startswith("platform."): + role_lc = (tenant.global_role or "").lower() + if not is_platform_admin(role_lc): + return { + "allowed": False, + "reason": "portal_role_required", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + cur.execute( + """ + SELECT 1 FROM portal_role_capability_grants + WHERE portal_role = %s AND capability_id = %s + LIMIT 1 + """, + (role_lc, capability_id), + ) + if not cur.fetchone(): + cur.execute( + """ + SELECT 1 FROM profile_capability_grants + WHERE profile_id = %s AND capability_id = %s + LIMIT 1 + """, + (tenant.profile_id, capability_id), + ) + if not cur.fetchone(): + return { + "allowed": False, + "reason": "portal_capability_denied", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + return { + "allowed": True, + "reason": "portal_granted", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + + # Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9) + if is_platform_admin(tenant.global_role): + return { + "allowed": True, + "reason": "platform_admin_bypass", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + + # Vereins-Capabilities: aktive Mitgliedschaft im Zielverein + if min_state == "active_member": + if eff_club is None: + return { + "allowed": False, + "reason": "no_club_context", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + if eff_club not in tenant.club_ids: + return { + "allowed": False, + "reason": "not_club_member", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + + cur.execute( + """ + SELECT role_code FROM club_role_capability_grants + WHERE capability_id = %s + """, + (capability_id,), + ) + required_roles = [r["role_code"] for r in cur.fetchall()] + + if required_roles: + if not any(r in required_roles for r in club_roles): + return { + "allowed": False, + "reason": "club_role_denied", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + elif min_state == "active_member" and eff_club is not None: + # Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht + pass + + return { + "allowed": True, + "reason": "granted", + "account_state": account_state, + "club_roles": club_roles, + "linked_feature_id": cap.get("linked_feature_id"), + } + + +def resolve_capabilities_map( + cur, + tenant: "TenantContext", + *, + club_id: Optional[int] = None, +) -> Dict[str, bool]: + """Alle aktiven Capabilities → bool (für späteres /me/entitlements).""" + cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id") + ids = [r["id"] for r in cur.fetchall()] + out: Dict[str, bool] = {} + for cid in ids: + res = check_capability(cur, tenant, cid, club_id=club_id) + out[cid] = bool(res.get("allowed")) + return out + + +def probe_capability( + tenant: "TenantContext", + capability_id: str, + *, + action: str, + club_id: Optional[int] = None, + endpoint: Optional[str] = None, + conn=None, +) -> Dict[str, Any]: + """Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1.""" + from capability_logger import log_capability_check + + def _run(c): + cur = get_cursor(c) + result = check_capability(cur, tenant, capability_id, club_id=club_id) + log_capability_check( + club_id=club_id if club_id is not None else tenant.effective_club_id, + profile_id=tenant.profile_id, + capability_id=capability_id, + action=action, + result=result, + endpoint=endpoint, + phase="enforce" if capability_enforcement_enabled() else "probe", + ) + if capability_enforcement_enabled() and not result.get("allowed"): + raise HTTPException( + status_code=403, + detail=( + f"Keine Berechtigung für {capability_id} " + f"({result.get('reason', 'denied')})." + ), + ) + return result + + if conn is not None: + return _run(conn) + with get_db() as c: + return _run(c) diff --git a/backend/capability_enforcement_audit.py b/backend/capability_enforcement_audit.py new file mode 100644 index 0000000..36c1158 --- /dev/null +++ b/backend/capability_enforcement_audit.py @@ -0,0 +1,94 @@ +""" +Audit: Welche Capabilities sind an Endpoints angebunden? + +Für Admin-Matrix (Rollen & Rechte) und Roadmap — bei neuem probe_capability hier eintragen. +""" +from __future__ import annotations + +from typing import Any, Dict + +# Endpoints rufen probe_capability auf (Log; Block nur bei CAPABILITY_ENFORCE=1) +WIRED_PROBE = frozenset( + { + "exercises.ai.suggest", + "exercises.ai.regenerate", + "exercises.create", + "exercises.media.upload", + "planning.ai.suggest", + "planning.ai.progression_path", + "club.creation_request.read_own", + "club.creation_request.create", + "club.creation_request.withdraw", + "platform.club_creation.approve", + } +) + +# Kontingent-Verbrauch nach Erfolg (consume_club_feature_with_usage) +FEATURE_CONSUME_WIRED = frozenset( + { + "ai_calls", + } +) + + +def enforcement_status_for_capability(capability_id: str) -> Dict[str, Any]: + """ + Anzeige-Status für Superadmin-Matrix. + + level: probe | legacy | platform | open | none + """ + cid = (capability_id or "").strip() + if cid in WIRED_PROBE: + return { + "level": "probe", + "label": "API vorbereitet (Log)", + "detail": "probe_capability am Endpoint; Hard-Block erst mit CAPABILITY_ENFORCE=1", + "implemented": True, + } + if cid.startswith("platform."): + if cid == "platform.admin.access": + return { + "level": "platform", + "label": "Plattform (Router-Guard)", + "detail": "RequireAdmin / Superadmin-Checks", + "implemented": True, + } + if cid in WIRED_PROBE: + pass + return { + "level": "platform", + "label": "Plattform (teilweise)", + "detail": "Meist Router-Guard; Capability-Probe nur wo eingetragen", + "implemented": cid in WIRED_PROBE, + } + if cid.startswith("club."): + return { + "level": "open", + "label": "Onboarding", + "detail": "Account-State / eigene Flows", + "implemented": cid in WIRED_PROBE, + } + # Vereins-Capabilities ohne Probe: Legacy club_tenancy (can_plan_in_club, has_club_role, …) + return { + "level": "legacy", + "label": "Nur Legacy-Rollen", + "detail": "Noch kein probe_capability — prüft can_plan_in_club / club_admin im Code", + "implemented": False, + } + + +def feature_consume_status(feature_id: str) -> Dict[str, Any]: + fid = (feature_id or "").strip() + if fid in FEATURE_CONSUME_WIRED: + return { + "level": "consume", + "label": "Verbrauch aktiv", + "detail": "consume_club_feature_with_usage + feature_usage in Response", + "implemented": True, + } + return { + "level": "inventory", + "label": "Bestand / Probe", + "detail": "Probe oder Live-Zählung; kein Consume nach Aktion", + "implemented": False, + } diff --git a/backend/capability_logger.py b/backend/capability_logger.py new file mode 100644 index 0000000..ccc905d --- /dev/null +++ b/backend/capability_logger.py @@ -0,0 +1,64 @@ +""" +JSON-Log für Capability-Checks (M3 Phase 2 — analog club_feature_logger). +""" +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + + +def _log_dir() -> Path: + custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip() + if custom: + return Path(custom) + return Path("/app/logs") + + +capability_logger = logging.getLogger("shinkan.capability_usage") +capability_logger.setLevel(logging.INFO) +capability_logger.propagate = False + +if not capability_logger.handlers: + log_dir = _log_dir() + try: + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "capability-usage.log" + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter("%(message)s")) + capability_logger.addHandler(file_handler) + except OSError: + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s")) + capability_logger.addHandler(stream_handler) + + +def log_capability_check( + *, + club_id: Optional[int], + profile_id: Optional[int], + capability_id: str, + action: str, + result: Dict[str, Any], + endpoint: Optional[str] = None, + phase: str = "probe", +) -> None: + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "club_id": club_id, + "profile_id": profile_id, + "capability": capability_id, + "action": action, + "endpoint": endpoint, + "phase": phase, + "allowed": result.get("allowed", True), + "reason": result.get("reason", "unknown"), + "account_state": result.get("account_state"), + "club_roles": result.get("club_roles"), + "enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1", + } + capability_logger.info(json.dumps(entry, ensure_ascii=False)) diff --git a/backend/club_feature_logger.py b/backend/club_feature_logger.py new file mode 100644 index 0000000..ce5cff8 --- /dev/null +++ b/backend/club_feature_logger.py @@ -0,0 +1,74 @@ +""" +JSON-Log für Vereins-Feature-Zugriffe (Phase 2: nur Monitoring, kein Block). + +Spez: CLUB_MEMBERSHIP_AND_FEATURES.v1.md §9 Phase 2 — analog Mitai feature_logger.py. +""" +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + + +def _log_dir() -> Path: + custom = (os.getenv("CLUB_FEATURE_LOG_DIR") or "").strip() + if custom: + return Path(custom) + return Path("/app/logs") + + +feature_usage_logger = logging.getLogger("shinkan.club_feature_usage") +feature_usage_logger.setLevel(logging.INFO) +feature_usage_logger.propagate = False + +if not feature_usage_logger.handlers: + log_dir = _log_dir() + try: + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "club-feature-usage.log" + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter("%(message)s")) + feature_usage_logger.addHandler(file_handler) + except OSError: + # Dev ohne /app/logs: Fallback stderr + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(logging.Formatter("[club-feature-usage] %(message)s")) + feature_usage_logger.addHandler(stream_handler) + + +def log_club_feature_usage( + *, + club_id: Optional[int], + profile_id: Optional[int], + feature_id: str, + action: str, + access: Dict[str, Any], + endpoint: Optional[str] = None, + phase: str = "probe", +) -> None: + """ + Strukturiertes JSON-Log eines Feature-Checks. + + phase: probe (Phase 2, non-blocking) | enforce (Phase 4, nach Block-Entscheid) + """ + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "club_id": club_id, + "profile_id": profile_id, + "feature": feature_id, + "action": action, + "endpoint": endpoint, + "phase": phase, + "plan_id": access.get("plan_id"), + "used": access.get("used", 0), + "limit": access.get("limit"), + "remaining": access.get("remaining"), + "allowed": access.get("allowed", True), + "reason": access.get("reason", "unknown"), + "enforcement": os.getenv("CLUB_FEATURE_ENFORCE", "0") == "1", + } + feature_usage_logger.info(json.dumps(entry, ensure_ascii=False)) diff --git a/backend/club_features.py b/backend/club_features.py new file mode 100644 index 0000000..3fbd937 --- /dev/null +++ b/backend/club_features.py @@ -0,0 +1,713 @@ +""" +Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id). + +Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md +Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block. +Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment. + +Verbrauch-Standard für Router: + probe_club_feature_access → Business-Logik → consume_club_feature_with_usage → merge_feature_usage_into_response + +Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen. +""" +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional, TYPE_CHECKING + +from fastapi import HTTPException + +from db import get_db, get_cursor + +if TYPE_CHECKING: + from tenant_context import TenantContext + +# Bestands-Features: Verbrauch = Live-Zählung in DB (nicht club_feature_usage) +_INVENTORY_FEATURES = frozenset( + {"exercises", "training_groups", "active_members", "training_programs"} +) + + +def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]: + """Nächster Reset-Zeitpunkt; None bei 'never'.""" + ref = now or datetime.now(timezone.utc) + if reset_period == "never": + return None + if reset_period == "daily": + tomorrow = ref.date() + timedelta(days=1) + return datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc) + if reset_period == "monthly": + if ref.month == 12: + return datetime(ref.year + 1, 1, 1, tzinfo=timezone.utc) + return datetime(ref.year, ref.month + 1, 1, tzinfo=timezone.utc) + return None + + +def _normalize_limit(raw: Any) -> Optional[int]: + """NULL = unbegrenzt; -1 (Legacy 001) wird als unbegrenzt behandelt.""" + if raw is None: + return None + try: + v = int(raw) + except (TypeError, ValueError): + return None + if v < 0: + return None + return v + + +def get_effective_club_plan(cur, club_id: int) -> str: + """ + Effektiver Plan für einen Verein. + + 1. Aktiver club_access_grants mit plan_id (Zeitfenster, neueste ends_at) + 2. club_subscriptions.status = 'active' → plan_id + 3. Fallback 'free' + """ + cur.execute( + """ + SELECT plan_id + FROM club_access_grants + WHERE club_id = %s + AND plan_id IS NOT NULL + AND starts_at <= NOW() + AND ends_at > NOW() + ORDER BY ends_at DESC + LIMIT 1 + """, + (club_id,), + ) + grant = cur.fetchone() + if grant and grant.get("plan_id"): + return str(grant["plan_id"]) + + cur.execute( + """ + SELECT plan_id + FROM club_subscriptions + WHERE club_id = %s AND status = 'active' + LIMIT 1 + """, + (club_id,), + ) + sub = cur.fetchone() + if sub and sub.get("plan_id"): + return str(sub["plan_id"]) + + return "free" + + +def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) -> Optional[int]: + """Limit-Wert: Override > Plan > Feature-Default.""" + cur.execute( + """ + SELECT limit_value + FROM club_feature_overrides + WHERE club_id = %s AND feature_id = %s + """, + (club_id, feature_id), + ) + override = cur.fetchone() + if override is not None: + return _normalize_limit(override.get("limit_value")) + + plan_id = get_effective_club_plan(cur, club_id) + cur.execute( + """ + SELECT limit_value + FROM club_plan_limits + WHERE plan_id = %s AND feature_id = %s + """, + (plan_id, feature_id), + ) + plan_lim = cur.fetchone() + if plan_lim is not None: + return _normalize_limit(plan_lim.get("limit_value")) + + return _normalize_limit(feature_row.get("default_limit")) + + +def _live_inventory_count(cur, club_id: int, feature_id: str) -> Optional[int]: + """Aktueller Bestand für reset_period=never Features.""" + if feature_id == "exercises": + cur.execute( + """ + SELECT COUNT(*)::int AS c + FROM exercises + WHERE club_id = %s AND status != 'archived' + """, + (club_id,), + ) + elif feature_id == "training_groups": + cur.execute( + "SELECT COUNT(*)::int AS c FROM training_groups WHERE club_id = %s", + (club_id,), + ) + elif feature_id == "active_members": + cur.execute( + """ + SELECT COUNT(*)::int AS c + FROM club_members + WHERE club_id = %s AND status = 'active' + """, + (club_id,), + ) + elif feature_id == "training_programs": + cur.execute( + """ + SELECT COUNT(*)::int AS c FROM ( + SELECT id FROM training_framework_programs WHERE club_id = %s + UNION ALL + SELECT id FROM training_modules WHERE club_id = %s + ) t + """, + (club_id, club_id), + ) + else: + return None + + row = cur.fetchone() + return int(row["c"] or 0) if row else 0 + + +def resolve_club_id_for_probe( + tenant: "TenantContext", + *, + object_club_id: Optional[int] = None, +) -> Optional[int]: + """Verein für Feature-Probe: explizites Objekt > effective_club_id.""" + if object_club_id is not None: + return int(object_club_id) + eff = getattr(tenant, "effective_club_id", None) + return int(eff) if eff is not None else None + + +def _maybe_reset_usage(cur, conn, club_id: int, feature_id: str, feature_row: dict, usage_row: Optional[dict]) -> int: + """Setzt Zähler zurück wenn reset_at überschritten; gibt aktuellen used zurück.""" + used = int(usage_row.get("usage_count") or 0) if usage_row else 0 + reset_at = usage_row.get("reset_at") if usage_row else None + period = (feature_row.get("reset_period") or "never").strip().lower() + + if not usage_row or not reset_at or period == "never": + return used + + now = datetime.now(timezone.utc) + ra = reset_at + if hasattr(ra, "tzinfo") and ra.tzinfo is None: + ra = ra.replace(tzinfo=timezone.utc) + + if ra and now > ra: + next_reset = _calculate_next_reset(period, now=now) + cur.execute( + """ + UPDATE club_feature_usage + SET usage_count = 0, reset_at = %s, updated_at = NOW() + WHERE club_id = %s AND feature_id = %s + """, + (next_reset, club_id, feature_id), + ) + conn.commit() + return 0 + + return used + + +def check_club_feature_access( + club_id: int, + feature_id: str, + *, + conn=None, +) -> Dict[str, Any]: + """ + Prüft Vereins-Kontingent für ein Feature. + + Returns: + allowed, limit, used, remaining, reason, plan_id, reset_at (optional) + """ + if conn is not None: + return _check_club_impl(club_id, feature_id, conn) + + with get_db() as c: + return _check_club_impl(club_id, feature_id, c) + + +def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]: + cur = get_cursor(conn) + + cur.execute( + """ + SELECT id, limit_type, reset_period, default_limit, active, enforcement_subject + FROM features + WHERE id = %s AND app = 'shinkan' + """, + (feature_id,), + ) + feature = cur.fetchone() + if not feature or not feature.get("active"): + return { + "allowed": False, + "limit": None, + "used": 0, + "remaining": None, + "reason": "feature_not_found", + "plan_id": get_effective_club_plan(cur, club_id), + } + + plan_id = get_effective_club_plan(cur, club_id) + limit = _resolve_club_limit(cur, club_id, feature_id, feature) + limit_type = (feature.get("limit_type") or "count").strip().lower() + + if limit_type == "boolean": + allowed = limit == 1 + return { + "allowed": allowed, + "limit": limit, + "used": 0, + "remaining": None, + "reason": "enabled" if allowed else "feature_disabled", + "plan_id": plan_id, + } + + cur.execute( + """ + SELECT usage_count, reset_at + FROM club_feature_usage + WHERE club_id = %s AND feature_id = %s + """, + (club_id, feature_id), + ) + usage = cur.fetchone() + used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage) + + period = (feature.get("reset_period") or "never").strip().lower() + if period == "never" and feature_id in _INVENTORY_FEATURES: + inv = _live_inventory_count(cur, club_id, feature_id) + if inv is not None: + used = inv + + if limit is None: + return { + "allowed": True, + "limit": None, + "used": used, + "remaining": None, + "reason": "unlimited", + "plan_id": plan_id, + "reset_at": usage.get("reset_at") if usage else None, + } + + if limit == 0: + return { + "allowed": False, + "limit": 0, + "used": used, + "remaining": 0, + "reason": "feature_disabled", + "plan_id": plan_id, + "reset_at": usage.get("reset_at") if usage else None, + } + + allowed = used < limit + return { + "allowed": allowed, + "limit": limit, + "used": used, + "remaining": max(0, limit - used), + "reason": "within_limit" if allowed else "limit_exceeded", + "plan_id": plan_id, + "reset_at": usage.get("reset_at") if usage else None, + } + + +def club_feature_enforcement_enabled() -> bool: + """Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes).""" + v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower() + return v in ("1", "true", "yes") + + +def probe_club_feature_access( + *, + feature_id: str, + action: str, + club_id: Optional[int] = None, + profile_id: Optional[int] = None, + portal_role: Optional[str] = None, + endpoint: Optional[str] = None, + tenant: Optional["TenantContext"] = None, + conn=None, +) -> Dict[str, Any]: + """ + Phase 2: Prüft Vereins-Kontingent, schreibt JSON-Log, blockiert standardmäßig nicht. + + Bei CLUB_FEATURE_ENFORCE=1: HTTP 403 wenn nicht allowed. + """ + from club_feature_logger import log_club_feature_usage + + if club_id is None: + access = { + "allowed": not club_feature_enforcement_enabled(), + "limit": None, + "used": 0, + "remaining": None, + "reason": "no_club_context", + "plan_id": None, + } + log_club_feature_usage( + club_id=None, + profile_id=profile_id, + feature_id=feature_id, + action=action, + access=access, + endpoint=endpoint, + phase="enforce" if club_feature_enforcement_enabled() else "probe", + ) + if club_feature_enforcement_enabled() and not access.get("allowed"): + raise HTTPException( + status_code=403, + detail=( + f"Kein Vereinskontext für {feature_id} — " + "aktiven Verein wählen (X-Active-Club-Id)." + ), + ) + return access + + def _resolve_access(connection): + from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access + + cur = get_cursor(connection) + if is_club_feature_quota_bypassed( + cur, + profile_id=profile_id, + portal_role=portal_role, + feature_id=feature_id, + tenant=tenant, + ): + plan_id = get_effective_club_plan(cur, int(club_id)) + return quota_bypass_access( + feature_id=feature_id, + club_id=int(club_id), + plan_id=plan_id, + ) + return check_club_feature_access(club_id, feature_id, conn=connection) + + if conn is not None: + access = _resolve_access(conn) + else: + with get_db() as c: + access = _resolve_access(c) + + log_club_feature_usage( + club_id=club_id, + profile_id=profile_id, + feature_id=feature_id, + action=action, + access=access, + endpoint=endpoint, + phase="enforce" if club_feature_enforcement_enabled() else "probe", + ) + + if club_feature_enforcement_enabled() and not access.get("allowed"): + limit = access.get("limit") + used = access.get("used", 0) + detail = ( + f"Kontingent überschritten für {feature_id} " + f"({used}/{limit if limit is not None else '∞'}). " + f"Grund: {access.get('reason', 'limit_exceeded')}." + ) + raise HTTPException(status_code=403, detail=detail) + + return access + + +def consume_club_feature( + *, + feature_id: str, + club_id: Optional[int], + profile_id: Optional[int] = None, + portal_role: Optional[str] = None, + action: Optional[str] = None, + amount: int = 1, + conn=None, +) -> None: + """ + Phase 4 (M5): Zähler nach erfolgreichem Verbrauch erhöhen. + Nur wenn club_id gesetzt (Vereins-Kontingent); amount = Anzahl LLM/API-Verbrauchseinheiten. + Plattform-Ausnahmen (superadmin, konfigurierte Rollen/Profile) werden nicht gezählt. + """ + if club_id is None: + return + + def _is_exempt(connection) -> bool: + from club_quota_bypass import is_club_feature_quota_bypassed + + cur = get_cursor(connection) + return is_club_feature_quota_bypassed( + cur, + profile_id=profile_id, + portal_role=portal_role, + feature_id=feature_id, + ) + + if conn is not None: + if _is_exempt(conn): + return + else: + with get_db() as c: + if _is_exempt(c): + return + try: + n = int(amount) + except (TypeError, ValueError): + n = 1 + if n < 1: + return + for _ in range(n): + increment_club_feature_usage( + int(club_id), + feature_id, + profile_id=profile_id, + action=action, + conn=conn, + ) + + def _log_consume(connection) -> None: + from club_feature_logger import log_club_feature_usage + + access = check_club_feature_access(int(club_id), feature_id, conn=connection) + log_club_feature_usage( + club_id=int(club_id), + profile_id=profile_id, + feature_id=feature_id, + action=action or "consume", + access=access, + phase="consume", + ) + + if conn is not None: + _log_consume(conn) + else: + with get_db() as c: + _log_consume(c) + + +def consume_club_feature_with_usage( + *, + feature_id: str, + club_id: Optional[int], + profile_id: Optional[int] = None, + portal_role: Optional[str] = None, + action: Optional[str] = None, + amount: int = 1, + cur, + tenant: Optional["TenantContext"] = None, + conn=None, +) -> Optional[Dict[str, Dict[str, Any]]]: + """ + Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response. + + Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und + ``merge_feature_usage_into_response`` — kein duplizierter Einzelcode pro Route. + """ + consume_club_feature( + feature_id=feature_id, + club_id=club_id, + profile_id=profile_id, + portal_role=portal_role, + action=action, + amount=amount, + conn=conn, + ) + if club_id is None: + return None + return { + feature_id: club_feature_usage_for_api( + cur, + club_id=int(club_id), + feature_id=feature_id, + profile_id=profile_id, + portal_role=portal_role, + tenant=tenant, + conn=conn, + ), + } + + +def merge_feature_usage_into_response( + payload: Any, + feature_usage: Optional[Dict[str, Dict[str, Any]]], +) -> Any: + """Standard-Einbettung ``feature_usage`` in JSON-Responses.""" + if not feature_usage or not isinstance(payload, dict): + return payload + return {**payload, "feature_usage": feature_usage} + + +def club_feature_usage_for_api( + cur, + *, + club_id: int, + feature_id: str, + profile_id: Optional[int] = None, + portal_role: Optional[str] = None, + tenant: Optional["TenantContext"] = None, + conn=None, +) -> Dict[str, Any]: + """Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch).""" + from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access + + db_conn = conn if conn is not None else cur.connection + access = check_club_feature_access(int(club_id), feature_id, conn=db_conn) + plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id)) + + if is_club_feature_quota_bypassed( + cur, + profile_id=profile_id, + portal_role=portal_role, + feature_id=feature_id, + tenant=tenant, + ): + ex = quota_bypass_access( + feature_id=feature_id, + club_id=int(club_id), + plan_id=plan_id, + ) + reset_at = access.get("reset_at") + return { + "allowed": True, + "used": access.get("used"), + "limit": None, + "remaining": None, + "reason": ex.get("reason"), + "platform_exempt": True, + "reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at, + } + + return { + "allowed": access.get("allowed"), + "used": access.get("used"), + "limit": access.get("limit"), + "remaining": access.get("remaining"), + "reason": access.get("reason"), + "platform_exempt": False, + "reset_at": access.get("reset_at").isoformat() + if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat") + else access.get("reset_at"), + } + + +def increment_club_feature_usage( + club_id: int, + feature_id: str, + *, + profile_id: Optional[int] = None, + action: Optional[str] = None, + conn=None, +) -> None: + """Erhöht Vereins-Zähler (nur bei neuem Verbrauch / INSERT-Pfad aufrufen).""" + def _run(c): + cur = get_cursor(c) + cur.execute( + """ + SELECT reset_period, limit_type + FROM features + WHERE id = %s AND app = 'shinkan' AND active = true + """, + (feature_id,), + ) + feature = cur.fetchone() + if not feature: + return + if (feature.get("limit_type") or "count").strip().lower() == "boolean": + return + + period = (feature.get("reset_period") or "never").strip().lower() + next_reset = _calculate_next_reset(period) + + cur.execute( + """ + INSERT INTO club_feature_usage (club_id, feature_id, usage_count, reset_at, last_used_at) + VALUES (%s, %s, 1, %s, NOW()) + ON CONFLICT (club_id, feature_id) + DO UPDATE SET + usage_count = club_feature_usage.usage_count + 1, + last_used_at = NOW(), + updated_at = NOW() + """, + (club_id, feature_id, next_reset), + ) + + if profile_id is not None or action: + cur.execute( + """ + INSERT INTO club_feature_usage_events (club_id, feature_id, profile_id, action) + VALUES (%s, %s, %s, %s) + """, + (club_id, feature_id, profile_id, action or feature_id), + ) + + if conn is not None: + _run(conn) + else: + with get_db() as c: + _run(c) + + +def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]: + """Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern).""" + db_conn = conn if conn is not None else cur.connection + plan_id = get_effective_club_plan(cur, club_id) + cur.execute( + """ + SELECT id, name, category, limit_type, reset_period + FROM features + WHERE app = 'shinkan' AND active = true + ORDER BY category, id + """ + ) + rows = cur.fetchall() + features_out = [] + for row in rows: + fid = row["id"] + access = _check_club_impl(club_id, fid, db_conn) + features_out.append( + { + "id": fid, + "name": row.get("name"), + "category": row.get("category"), + "limit_type": row.get("limit_type"), + "reset_period": row.get("reset_period"), + "allowed": access.get("allowed"), + "limit": access.get("limit"), + "used": access.get("used"), + "remaining": access.get("remaining"), + "reason": access.get("reason"), + "reset_at": access.get("reset_at"), + } + ) + return {"club_id": club_id, "plan_id": plan_id, "features": features_out} + + +def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]: + """Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements).""" + raw = list_club_entitlements(cur, club_id, conn=conn) + features_dict: Dict[str, Any] = {} + for row in raw.get("features") or []: + fid = row["id"] + features_dict[fid] = { + "name": row.get("name"), + "category": row.get("category"), + "limit_type": row.get("limit_type"), + "reset_period": row.get("reset_period"), + "allowed": row.get("allowed"), + "limit": row.get("limit"), + "used": row.get("used"), + "remaining": row.get("remaining"), + "reason": row.get("reason"), + "reset_at": row.get("reset_at"), + } + return { + "club_id": raw.get("club_id"), + "plan_id": raw.get("plan_id"), + "features": features_dict, + } diff --git a/backend/club_quota_bypass.py b/backend/club_quota_bypass.py new file mode 100644 index 0000000..2df142e --- /dev/null +++ b/backend/club_quota_bypass.py @@ -0,0 +1,180 @@ +""" +Vereins-Kontingent-Bypass über das Capability-System (kein Parallel-Rechtemodell). + +Capabilities: + - platform.club_quota.bypass — alle Vereins-Features (Portal-Admin, Grant via portal_role) + - platform.club_quota.bypass.{feature_id} — ein Feature (domain quota_bypass, auch für Nicht-Admins per Grant) +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from tenant_context import TenantContext + +QUOTA_BYPASS_ALL = "platform.club_quota.bypass" +QUOTA_BYPASS_FEATURE_PREFIX = "platform.club_quota.bypass." + + +def quota_bypass_capability_id_for_feature(feature_id: str) -> str: + return f"{QUOTA_BYPASS_FEATURE_PREFIX}{feature_id}" + + +def ensure_quota_bypass_capability(cur, feature_id: str) -> str: + """Legt feature-spezifische Bypass-Capability an falls nötig.""" + cap_id = quota_bypass_capability_id_for_feature(feature_id) + cur.execute( + """ + INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) + VALUES (%s, %s, 'quota_bypass', 'active_member', %s) + ON CONFLICT (id) DO NOTHING + """, + (cap_id, f"Vereins-Kontingent umgehen: {feature_id}", feature_id), + ) + return cap_id + + +def _bypass_capability_ids(cur, feature_id: str) -> List[str]: + ids: List[str] = [QUOTA_BYPASS_ALL, quota_bypass_capability_id_for_feature(feature_id)] + cur.execute( + """ + SELECT id FROM capabilities + WHERE active = true + AND domain = 'quota_bypass' + AND linked_feature_id = %s + AND id <> %s + """, + (feature_id, quota_bypass_capability_id_for_feature(feature_id)), + ) + for row in cur.fetchall(): + cid = row.get("id") + if cid and cid not in ids: + ids.append(str(cid)) + return ids + + +def _portal_role_has_grant(cur, portal_role: str, capability_id: str) -> bool: + role = (portal_role or "").strip().lower() + if not role: + return False + cur.execute( + """ + SELECT 1 FROM portal_role_capability_grants + WHERE portal_role = %s AND capability_id = %s + LIMIT 1 + """, + (role, capability_id), + ) + return cur.fetchone() is not None + + +def _profile_has_grant(cur, profile_id: int, capability_id: str) -> bool: + cur.execute( + """ + SELECT 1 FROM profile_capability_grants + WHERE profile_id = %s AND capability_id = %s + LIMIT 1 + """, + (int(profile_id), capability_id), + ) + return cur.fetchone() is not None + + +def is_club_feature_quota_bypassed( + cur, + *, + profile_id: Optional[int], + portal_role: Optional[str], + feature_id: str, + tenant: Optional["TenantContext"] = None, +) -> bool: + """ + True wenn ein konfigurierter Capability-Grant das Vereins-Kontingent für feature_id umgeht. + """ + if tenant is not None: + from capabilities import check_capability + + for cap_id in _bypass_capability_ids(cur, feature_id): + if check_capability(cur, tenant, cap_id).get("allowed"): + return True + return False + + for cap_id in _bypass_capability_ids(cur, feature_id): + if _portal_role_has_grant(cur, portal_role or "", cap_id): + return True + if profile_id is not None and _profile_has_grant(cur, int(profile_id), cap_id): + return True + return False + + +def quota_bypass_access( + *, + feature_id: str, + club_id: Optional[int] = None, + plan_id: Optional[str] = None, + capability_id: Optional[str] = None, +) -> Dict[str, Any]: + return { + "allowed": True, + "limit": None, + "used": 0, + "remaining": None, + "reason": "capability_quota_bypass", + "platform_exempt": True, + "quota_bypass_capability": capability_id, + "plan_id": plan_id, + "club_id": club_id, + "feature_id": feature_id, + } + + +def list_quota_bypass_grants(cur) -> Dict[str, Any]: + """Admin: alle Grants zu Kontingent-Bypass-Capabilities.""" + cur.execute( + """ + SELECT g.portal_role, g.capability_id, c.name AS capability_name, + c.linked_feature_id, c.domain + FROM portal_role_capability_grants g + INNER JOIN capabilities c ON c.id = g.capability_id + WHERE g.capability_id = %s + OR g.capability_id LIKE %s + OR c.domain = 'quota_bypass' + ORDER BY g.portal_role, g.capability_id + """, + (QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"), + ) + portal_grants = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT g.profile_id, p.email, p.name AS profile_name, + g.capability_id, c.name AS capability_name, c.linked_feature_id, + g.reason, g.granted_by_profile_id, g.created_at + FROM profile_capability_grants g + INNER JOIN profiles p ON p.id = g.profile_id + INNER JOIN capabilities c ON c.id = g.capability_id + WHERE g.capability_id = %s + OR g.capability_id LIKE %s + OR c.domain = 'quota_bypass' + ORDER BY g.profile_id, g.capability_id + """, + (QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"), + ) + profile_grants = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, name, domain, linked_feature_id + FROM capabilities + WHERE id = %s OR id LIKE %s OR domain = 'quota_bypass' + ORDER BY id + """, + (QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"), + ) + capabilities = [dict(r) for r in cur.fetchall()] + + return { + "capabilities": capabilities, + "portal_role_grants": portal_grants, + "profile_grants": profile_grants, + } diff --git a/backend/entitlements.py b/backend/entitlements.py new file mode 100644 index 0000000..575e1b3 --- /dev/null +++ b/backend/entitlements.py @@ -0,0 +1,113 @@ +""" +Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4). + +Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, Optional, TYPE_CHECKING + +from fastapi import HTTPException + +from capabilities import club_roles_in_club, resolve_capabilities_map +from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access +from club_features import club_features_map +from club_tenancy import is_platform_admin +from tenant_context import _club_exists + +if TYPE_CHECKING: + from tenant_context import TenantContext + + +def _serialize_reset_at(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=None).isoformat() + "Z" + return value.isoformat() + return str(value) + + +def _resolve_target_club_id( + cur, + tenant: "TenantContext", + club_id: Optional[int], +) -> Optional[int]: + """Effektiver Verein für Entitlements (Query > Tenant).""" + target = int(club_id) if club_id is not None else tenant.effective_club_id + if target is None: + return None + + if is_platform_admin(tenant.global_role): + if not _club_exists(cur, target): + raise HTTPException(status_code=400, detail="Verein nicht gefunden") + return target + + if target not in tenant.club_ids: + raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein") + return target + + +def build_me_entitlements( + cur, + tenant: "TenantContext", + *, + club_id: Optional[int] = None, +) -> Dict[str, Any]: + """ + Kombiniert Account-Status, Capabilities und Feature-Kontingente. + """ + target_club = _resolve_target_club_id(cur, tenant, club_id) + club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else [] + + capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club) + + features: Dict[str, Any] = {} + plan_id = None + if target_club is not None: + raw = club_features_map(cur, target_club) + plan_id = raw.get("plan_id") + for fid, row in (raw.get("features") or {}).items(): + if is_club_feature_quota_bypassed( + cur, + profile_id=tenant.profile_id, + portal_role=tenant.global_role, + feature_id=fid, + tenant=tenant, + ): + ex = quota_bypass_access( + feature_id=fid, + club_id=target_club, + plan_id=plan_id, + ) + features[fid] = { + "allowed": True, + "used": row.get("used"), + "limit": None, + "remaining": None, + "reset_at": _serialize_reset_at(row.get("reset_at")), + "reason": ex.get("reason"), + "platform_exempt": True, + } + else: + features[fid] = { + "allowed": row.get("allowed"), + "used": row.get("used"), + "limit": row.get("limit"), + "remaining": row.get("remaining"), + "reset_at": _serialize_reset_at(row.get("reset_at")), + "reason": row.get("reason"), + "platform_exempt": False, + } + + return { + "account_state": tenant.account_state, + "portal_role": tenant.global_role, + "club_id": target_club, + "plan_id": plan_id, + "club_roles": club_roles, + "capabilities": capabilities, + "features": features, + } diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index 5b1c73e..c744c9b 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -650,10 +650,13 @@ def build_exercise_placeholder_variables( focus_areas_context: Optional[Sequence[Tuple[int, bool]]], preparation: Optional[str] = None, trainer_notes: Optional[str] = None, + planning_context: Optional[Mapping[str, Any]] = None, ) -> Dict[str, str]: """ Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI. """ + from planning_exercise_form_context import planning_context_prompt_variables + s = (slug or "").strip().lower() if s == "pipeline": return {} @@ -671,8 +674,19 @@ def build_exercise_placeholder_variables( "exercise_preparation": p_plain or "-", "exercise_trainer_notes": n_plain or "-", } + ctx.update(planning_context_prompt_variables(planning_context)) if s == "exercise_summary": - return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")} + return { + k: ctx[k] + for k in ( + "exercise_title", + "exercise_focus_area", + "exercise_goal", + "exercise_execution", + "planning_context_json", + "has_planning_context", + ) + } if s == "exercise_instruction_rewrite": return ctx if s == "exercise_skill_suggestions": @@ -893,6 +907,7 @@ def run_exercise_ai_suggestion( execution=execution, focus_area_hint=focus_area_hint, focus_areas_context=focus_areas_context, + planning_context=form_ctx.planning_context, ) except ValueError as e: raise HTTPException(status_code=500, detail=str(e)) from e @@ -938,6 +953,7 @@ def run_exercise_ai_suggestion( execution=execution, focus_area_hint=focus_area_hint, focus_areas_context=focus_areas_context, + planning_context=form_ctx.planning_context, ) except ValueError as e: raise HTTPException(status_code=500, detail=str(e)) from e @@ -1015,6 +1031,7 @@ def run_exercise_ai_suggestion( trainer_notes=trainer_notes, focus_area_hint=focus_area_hint, focus_areas_context=focus_areas_context, + planning_context=form_ctx.planning_context, ) except ValueError as e: raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/backend/main.py b/backend/main.py index 93b1e36..a1a6cad 100644 --- a/backend/main.py +++ b/backend/main.py @@ -52,6 +52,28 @@ else: print(f"[FAIL] Migration-Laufzeitfehler: {e}") sys.exit(1) +# Registry-first: Module → DB (nur registrierte Rechte/Kontingente in Admin-Matrix) +if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"): + try: + from rights_registry import sync_rights_registry_to_db + + counts = sync_rights_registry_to_db() + print( + f"[OK] Rights registry sync: {counts['capabilities']} capabilities, " + f"{counts['features']} features" + ) + except Exception as e: + print(f"[FAIL] Rights registry sync: {e}") + sys.exit(1) + + from club_features import club_feature_enforcement_enabled + + _cfe = os.getenv("CLUB_FEATURE_ENFORCE", "0") + print( + f"[OK] CLUB_FEATURE_ENFORCE raw={_cfe!r} " + f"active={club_feature_enforcement_enabled()}" + ) + from routers.auth import limiter as auth_rate_limiter # OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1 @@ -87,6 +109,34 @@ app.add_middleware( ) +@app.middleware("http") +async def account_onboarding_api_gate(request: Request, call_next): + """ + Phase A: Domänen-APIs für unverified / verified_pending_club sperren. + Siehe account_onboarding_gate.py und MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1 + """ + from account_onboarding_gate import evaluate_request_gate + + token = request.headers.get("x-auth-token") or request.headers.get("X-Auth-Token") + allowed, reason, _state = evaluate_request_gate( + token, + request.url.path, + request.method, + ) + if not allowed: + return JSONResponse( + status_code=403, + content={ + "detail": ( + "Zugriff erst nach E-Mail-Bestätigung und Vereinsmitgliedschaft möglich. " + "Du kannst einen Beitrittsantrag stellen oder dein Konto in den Einstellungen verwalten." + ), + "reason": reason, + }, + ) + return await call_next(request) + + @app.middleware("http") async def add_api_security_headers(request: Request, call_next): """Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing).""" @@ -193,7 +243,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin app.include_router(auth.router) app.include_router(profiles.router) @@ -202,8 +252,11 @@ app.include_router(exercise_progression_graphs.router) app.include_router(clubs.router) app.include_router(club_memberships.router) app.include_router(club_join_requests.router) +app.include_router(club_creation_requests.router) app.include_router(admin_users.router) app.include_router(admin_user_content.router) +app.include_router(admin_rights.router) +app.include_router(me_entitlements.router) app.include_router(platform_media_storage.router) app.include_router(media_assets.router) app.include_router(media_assets.admin_rights_router) diff --git a/backend/migrations/078_ai_prompt_planning_progression_roadmap.sql b/backend/migrations/078_ai_prompt_planning_progression_roadmap.sql new file mode 100644 index 0000000..10b5b30 --- /dev/null +++ b/backend/migrations/078_ai_prompt_planning_progression_roadmap.sql @@ -0,0 +1,74 @@ +-- Migration 078: Planungs-KI Phase F — Progressions-Roadmap Prompts (Zielanalyse + Roadmap) + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'planning_progression_goal_analysis', + 'Progressions-Roadmap Zielanalyse', + 'Phase A: Ist-/Soll-Zustand und Erfolgskriterien für einen Progressionsgraphen (ohne Gruppenkontext).', + $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen. + +Anfrage: {{goal_query}} +Semantic Brief: {{semantic_brief_json}} + +Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema. + +Antworte NUR mit JSON: +{ + "primary_topic": "Mae Geri", + "start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen", + "target_state": "Konkreter Zielzustand der Progression", + "success_criteria": ["messbare Kriterien"], + "constraints": { "partner_required": false } +}$t$, + 'training', + 'json', + '{"type":"object","properties":{"primary_topic":{"type":"string"},"target_state":{"type":"string"},"success_criteria":{"type":"array"}}}'::jsonb, + true, + NULL, + true, + 14 +WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_goal_analysis'); + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'planning_progression_roadmap', + 'Progressions-Roadmap Major Steps', + 'Phase B: 8–12 micro_objectives, Konsolidierung auf N major_steps.', + $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen. + +Anfrage: {{goal_query}} +Zielanalyse: {{goal_analysis_json}} +Semantic Brief: {{semantic_brief_json}} +Anzahl Major Steps (N): {{max_steps}} + +Erzeuge zuerst 8–12 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps. +Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — in sinnvoller Reihenfolge (Grundlagen vor Perfektion). + +Antworte NUR mit JSON: +{ + "micro_objectives": [ + { "id": "m1", "phase": "grundlage", "title": "…", "weight": 0.9, "depends_on": [] } + ], + "major_steps": [ + { "index": 0, "phase": "grundlage", "learning_goal": "…", "consolidates": ["m1","m2"], "rationale": "…" } + ], + "consolidation_notes": ["…"] +}$t$, + 'training', + 'json', + '{"type":"object","properties":{"micro_objectives":{"type":"array"},"major_steps":{"type":"array"},"consolidation_notes":{"type":"array"}}}'::jsonb, + true, + NULL, + true, + 15 +WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_roadmap'); + +UPDATE ai_prompts SET default_template = template +WHERE slug IN ('planning_progression_goal_analysis', 'planning_progression_roadmap') + AND (default_template IS NULL OR TRIM(default_template) = ''); diff --git a/backend/migrations/078_club_features_and_plans.sql b/backend/migrations/078_club_features_and_plans.sql new file mode 100644 index 0000000..7bbe3df --- /dev/null +++ b/backend/migrations/078_club_features_and_plans.sql @@ -0,0 +1,286 @@ +-- Migration 078: Vereins-Feature-Registry (Mitai-v9c-Pattern) + club_plans/subscriptions +-- Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md (M1) +-- Legacy 001 (SERIAL features, profile tier_limits) wird archiviert, nicht gelöscht. + +-- ── 1. Legacy-Tabellen archivieren (nur alte Struktur) ───────────────────── +DO $migration$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'features' + ) AND EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'name' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type' + ) THEN + -- Nach abgebrochenem Erstversuch kann features_legacy_001 schon existieren + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'features_legacy_001' + ) THEN + DROP TABLE features; + ELSE + ALTER TABLE features RENAME TO features_legacy_001; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'tier_limits' + ) AND EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'tier_limits' AND column_name = 'tier' + ) THEN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'tier_limits_legacy_001' + ) THEN + DROP TABLE tier_limits; + ELSE + ALTER TABLE tier_limits RENAME TO tier_limits_legacy_001; + END IF; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'user_feature_usage' + ) AND EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'user_feature_usage' AND column_name = 'profile_id' + ) THEN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'user_feature_usage_legacy_001' + ) THEN + DROP TABLE user_feature_usage; + ELSE + ALTER TABLE user_feature_usage RENAME TO user_feature_usage_legacy_001; + END IF; + END IF; +END +$migration$; + +-- ── 2. Feature-Registry (TEXT-PK, app=shinkan) ──────────────────────────── +CREATE TABLE IF NOT EXISTS features ( + id TEXT PRIMARY KEY, + app TEXT NOT NULL DEFAULT 'shinkan', + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'content', + limit_type TEXT NOT NULL DEFAULT 'count' + CHECK (limit_type IN ('count', 'boolean')), + reset_period TEXT NOT NULL DEFAULT 'never' + CHECK (reset_period IN ('never', 'daily', 'monthly')), + default_limit INTEGER, + enforcement_subject TEXT NOT NULL DEFAULT 'club' + CHECK (enforcement_subject IN ('club', 'profile', 'portal')), + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_features_app ON features(app) WHERE active = true; + +-- ── 3. Vereins-Produkte ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS club_plans ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + price_monthly_cents INTEGER, + price_yearly_cents INTEGER, + stripe_price_id_monthly TEXT, + stripe_price_id_yearly TEXT, + active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS club_plan_limits ( + id SERIAL PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + limit_value INTEGER, + UNIQUE (plan_id, feature_id) +); + +CREATE INDEX IF NOT EXISTS idx_club_plan_limits_plan ON club_plan_limits(plan_id); + +CREATE TABLE IF NOT EXISTS club_subscriptions ( + id SERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + plan_id TEXT NOT NULL REFERENCES club_plans(id), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'trial', 'past_due', 'cancelled')), + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ends_at TIMESTAMPTZ, + trial_ends_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (club_id) +); + +CREATE INDEX IF NOT EXISTS idx_club_subscriptions_plan ON club_subscriptions(plan_id); + +CREATE TABLE IF NOT EXISTS club_feature_overrides ( + id SERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + limit_value INTEGER NOT NULL, + reason TEXT, + set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (club_id, feature_id) +); + +CREATE TABLE IF NOT EXISTS club_access_grants ( + id SERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + plan_id TEXT REFERENCES club_plans(id) ON DELETE SET NULL, + feature_id TEXT REFERENCES features(id) ON DELETE SET NULL, + grant_limit INTEGER, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + reason TEXT, + created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_club_access_grants_club ON club_access_grants(club_id); +CREATE INDEX IF NOT EXISTS idx_club_access_grants_window ON club_access_grants(club_id, starts_at, ends_at); + +CREATE TABLE IF NOT EXISTS club_feature_usage ( + id SERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + usage_count INTEGER NOT NULL DEFAULT 0, + reset_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (club_id, feature_id) +); + +CREATE INDEX IF NOT EXISTS idx_club_feature_usage_club ON club_feature_usage(club_id); + +CREATE TABLE IF NOT EXISTS club_feature_usage_events ( + id BIGSERIAL PRIMARY KEY, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + action TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_club_feature_usage_events_club + ON club_feature_usage_events(club_id, created_at DESC); + +-- ── 4. Seed: Features ───────────────────────────────────────────────────── +INSERT INTO features (id, app, name, description, category, limit_type, reset_period, default_limit, enforcement_subject) +VALUES + ('exercises', 'shinkan', 'Übungen', 'Anzahl Übungen im Verein (Bestand)', 'content', 'count', 'never', 100, 'club'), + ('exercise_media', 'shinkan', 'Medien-Uploads', 'Medien-Uploads pro Monat', 'content', 'count', 'monthly', 20, 'club'), + ('training_units', 'shinkan', 'Trainingseinheiten', 'Trainingseinheiten pro Monat', 'planning', 'count', 'monthly', 40, 'club'), + ('training_programs', 'shinkan', 'Trainingsprogramme', 'Module und Rahmenprogramme (Bestand)', 'planning', 'count', 'never', 5, 'club'), + ('training_groups', 'shinkan', 'Trainingsgruppen', 'Anzahl Trainingsgruppen', 'org', 'count', 'never', 10, 'club'), + ('active_members', 'shinkan', 'Aktive Mitglieder', 'Anzahl aktiver Vereinsmitglieder', 'org', 'count', 'never', 25, 'club'), + ('ai_calls', 'shinkan', 'KI-Aufrufe', 'KI-Aufrufe pro Monat (Suggest, Regenerate, Planung)', 'ai', 'count', 'monthly', 0, 'club'), + ('ai_pipeline', 'shinkan', 'KI-Pipeline', 'Erweiterte KI-Batch-Pipelines', 'ai', 'boolean', 'never', 0, 'club'), + ('wiki_import', 'shinkan', 'Wiki-Import', 'MediaWiki-Import (Plattform)', 'integration', 'boolean', 'never', 0, 'portal'), + ('data_export', 'shinkan', 'Daten-Export', 'Export-Funktionen', 'integration', 'boolean', 'never', 0, 'club') +ON CONFLICT (id) DO NOTHING; + +-- ── 5. Seed: Pläne ────────────────────────────────────────────────────────── +INSERT INTO club_plans (id, name, description, sort_order, active) +VALUES + ('free', 'Free', 'Einstieg für Vereine', 0, true), + ('verein_starter', 'Verein Starter', 'Erweiterte Kontingente', 10, true), + ('verein_pro', 'Verein Pro', 'Hohe Limits und KI-Kontingent', 20, true), + ('pilot', 'Pilot', 'Pilotverein mit großzügigen Limits', 5, true) +ON CONFLICT (id) DO NOTHING; + +-- Plan-Limits: free +INSERT INTO club_plan_limits (plan_id, feature_id, limit_value) +SELECT 'free', f.id, + CASE f.id + WHEN 'exercises' THEN 100 + WHEN 'exercise_media' THEN 20 + WHEN 'training_units' THEN 40 + WHEN 'training_programs' THEN 5 + WHEN 'training_groups' THEN 10 + WHEN 'active_members' THEN 25 + WHEN 'ai_calls' THEN 0 + WHEN 'ai_pipeline' THEN 0 + WHEN 'wiki_import' THEN 0 + WHEN 'data_export' THEN 0 + END +FROM features f +WHERE f.app = 'shinkan' +ON CONFLICT (plan_id, feature_id) DO NOTHING; + +-- Plan-Limits: verein_starter +INSERT INTO club_plan_limits (plan_id, feature_id, limit_value) +SELECT 'verein_starter', f.id, + CASE f.id + WHEN 'exercises' THEN 500 + WHEN 'exercise_media' THEN 80 + WHEN 'training_units' THEN 200 + WHEN 'training_programs' THEN 30 + WHEN 'training_groups' THEN 30 + WHEN 'active_members' THEN 80 + WHEN 'ai_calls' THEN 30 + WHEN 'ai_pipeline' THEN 0 + WHEN 'wiki_import' THEN 0 + WHEN 'data_export' THEN 1 + END +FROM features f +WHERE f.app = 'shinkan' +ON CONFLICT (plan_id, feature_id) DO NOTHING; + +-- Plan-Limits: verein_pro (NULL = unbegrenzt wo sinnvoll) +INSERT INTO club_plan_limits (plan_id, feature_id, limit_value) +SELECT 'verein_pro', f.id, + CASE f.id + WHEN 'exercises' THEN NULL + WHEN 'exercise_media' THEN 300 + WHEN 'training_units' THEN NULL + WHEN 'training_programs' THEN NULL + WHEN 'training_groups' THEN NULL + WHEN 'active_members' THEN NULL + WHEN 'ai_calls' THEN 200 + WHEN 'ai_pipeline' THEN 1 + WHEN 'wiki_import' THEN 0 + WHEN 'data_export' THEN 1 + END +FROM features f +WHERE f.app = 'shinkan' +ON CONFLICT (plan_id, feature_id) DO NOTHING; + +-- Plan-Limits: pilot +INSERT INTO club_plan_limits (plan_id, feature_id, limit_value) +SELECT 'pilot', f.id, + CASE f.id + WHEN 'exercises' THEN NULL + WHEN 'exercise_media' THEN NULL + WHEN 'training_units' THEN NULL + WHEN 'training_programs' THEN NULL + WHEN 'training_groups' THEN NULL + WHEN 'active_members' THEN NULL + WHEN 'ai_calls' THEN 100 + WHEN 'ai_pipeline' THEN 1 + WHEN 'wiki_import' THEN 0 + WHEN 'data_export' THEN 1 + END +FROM features f +WHERE f.app = 'shinkan' +ON CONFLICT (plan_id, feature_id) DO NOTHING; + +-- ── 6. Backfill: bestehende Vereine → Plan free ─────────────────────────── +INSERT INTO club_subscriptions (club_id, plan_id, status) +SELECT c.id, 'free', 'active' +FROM clubs c +WHERE NOT EXISTS ( + SELECT 1 FROM club_subscriptions cs WHERE cs.club_id = c.id +); diff --git a/backend/migrations/079_ai_prompt_planning_progression_stage_spec.sql b/backend/migrations/079_ai_prompt_planning_progression_stage_spec.sql new file mode 100644 index 0000000..099140a --- /dev/null +++ b/backend/migrations/079_ai_prompt_planning_progression_stage_spec.sql @@ -0,0 +1,43 @@ +-- Migration 079: Planungs-KI Phase F — Stufenspezifikation (Prompt in ai_prompts, nicht im Code) + +INSERT INTO ai_prompts ( + slug, display_name, description, template, + category, output_format, output_schema, is_system_default, default_template, active, sort_order +) +SELECT + 'planning_progression_stage_spec', + 'Progressions-Roadmap Stufenspezifikation', + 'Phase C: Belastungsprofil, Übungstyp und Erfolgskriterien je Major Step.', + $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen. + +Anfrage: {{goal_query}} +Zielanalyse: {{goal_analysis_json}} +Major Steps: {{major_steps_json}} + +Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug). + +Antworte NUR mit JSON: +{ + "stage_specs": [ + { + "major_step_index": 0, + "learning_goal": "…", + "load_profile": ["koordination", "gleichgewicht"], + "exercise_type": "kihon_einzel", + "success_criteria": ["…"], + "anti_patterns": ["…"] + } + ] +}$t$, + 'training', + 'json', + '{"type":"object","properties":{"stage_specs":{"type":"array"}}}'::jsonb, + true, + NULL, + true, + 16 +WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_stage_spec'); + +UPDATE ai_prompts SET default_template = template +WHERE slug = 'planning_progression_stage_spec' + AND (default_template IS NULL OR TRIM(default_template) = ''); diff --git a/backend/migrations/079_capabilities.sql b/backend/migrations/079_capabilities.sql new file mode 100644 index 0000000..24eba96 --- /dev/null +++ b/backend/migrations/079_capabilities.sql @@ -0,0 +1,225 @@ +-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1) +-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py). +-- Voraussetzung: Migration 078 (features.id TEXT). Kein FK auf features — vermeidet +-- Startup-Abbruch wenn 078 noch aussteht oder features-Schema driftet (001 vs v9c). + +DO $migration$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type' + ) THEN + RAISE EXCEPTION + 'Migration 079: features-Tabelle nicht v9c (limit_type fehlt). Zuerst 078_club_features_and_plans anwenden.'; + END IF; +END +$migration$; + +CREATE TABLE IF NOT EXISTS capabilities ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + domain TEXT NOT NULL, + min_account_state TEXT NOT NULL DEFAULT 'active_member' + CHECK (min_account_state IN ( + 'unverified', 'verified_pending_club', 'active_member', 'platform_admin' + )), + linked_feature_id TEXT, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_capabilities_domain ON capabilities(domain) WHERE active = true; + +CREATE TABLE IF NOT EXISTS club_role_capability_grants ( + role_code TEXT NOT NULL, + capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE, + PRIMARY KEY (role_code, capability_id) +); + +CREATE INDEX IF NOT EXISTS idx_club_role_cap_grants_cap ON club_role_capability_grants(capability_id); + +CREATE TABLE IF NOT EXISTS portal_role_capability_grants ( + portal_role TEXT NOT NULL, + capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE, + PRIMARY KEY (portal_role, capability_id) +); + +-- ── Seed: Capabilities (v1 Katalog §5) ─────────────────────────────────────── +INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) VALUES + ('account.settings.read', 'Einstellungen lesen', 'account', 'unverified', NULL), + ('account.settings.update', 'Einstellungen ändern', 'account', 'unverified', NULL), + ('account.password.change', 'Passwort ändern', 'account', 'unverified', NULL), + ('account.resend_verification', 'Verifizierung erneut senden', 'account', 'unverified', NULL), + ('club.directory.read', 'Vereinsverzeichnis', 'club', 'verified_pending_club', NULL), + ('club.join_request.create', 'Vereinsbeitritt beantragen', 'club', 'verified_pending_club', NULL), + ('club.join_request.withdraw', 'Beitrittsantrag zurückziehen', 'club', 'verified_pending_club', NULL), + ('club.join_request.read_own', 'Eigene Beitrittsanträge', 'club', 'verified_pending_club', NULL), + ('org.club.read', 'Vereine lesen', 'org', 'active_member', NULL), + ('org.club.create', 'Verein anlegen', 'org', 'platform_admin', NULL), + ('org.club.update', 'Verein bearbeiten', 'org', 'active_member', NULL), + ('org.club.delete', 'Verein löschen', 'org', 'platform_admin', NULL), + ('org.structure.manage', 'Vereinsstruktur verwalten', 'org', 'active_member', 'training_groups'), + ('org.members.read', 'Mitgliederliste', 'org', 'active_member', NULL), + ('org.members.manage', 'Mitglieder verwalten', 'org', 'active_member', 'active_members'), + ('org.members.directory', 'Mitglieder-Verzeichnis', 'org', 'active_member', NULL), + ('org.join_request.review', 'Beitrittsanträge prüfen', 'org', 'active_member', NULL), + ('org.inbox.read', 'Posteingang', 'org', 'active_member', NULL), + ('exercises.read', 'Übungen lesen', 'exercises', 'active_member', NULL), + ('exercises.create', 'Übung anlegen', 'exercises', 'active_member', 'exercises'), + ('exercises.update', 'Übung bearbeiten', 'exercises', 'active_member', NULL), + ('exercises.delete', 'Übung löschen', 'exercises', 'active_member', NULL), + ('exercises.bulk_metadata', 'Übungen Stapel-Metadaten', 'exercises', 'active_member', NULL), + ('exercises.ai.suggest', 'KI-Vorschlag Übung', 'exercises', 'active_member', 'ai_calls'), + ('exercises.ai.regenerate', 'KI neu generieren', 'exercises', 'active_member', 'ai_calls'), + ('exercises.media.read', 'Übungsmedien lesen', 'exercises', 'active_member', NULL), + ('exercises.media.upload', 'Übungsmedien hochladen', 'exercises', 'active_member', 'exercise_media'), + ('exercises.variants.manage', 'Übungsvarianten', 'exercises', 'active_member', NULL), + ('media.library.read', 'Medienbibliothek lesen', 'media', 'active_member', NULL), + ('media.library.upload', 'Medienbibliothek Upload', 'media', 'active_member', 'exercise_media'), + ('media.library.update', 'Medienbibliothek bearbeiten', 'media', 'active_member', NULL), + ('media.library.lifecycle', 'Medien-Lifecycle', 'media', 'active_member', NULL), + ('media.rights.declare', 'Medienrechte erklären', 'media', 'active_member', NULL), + ('media.admin.rights_review', 'Medienrechte Review (Plattform)', 'media', 'platform_admin', NULL), + ('modules.read', 'Trainingsmodule lesen', 'modules', 'active_member', NULL), + ('modules.create', 'Trainingsmodul anlegen', 'modules', 'active_member', 'training_programs'), + ('modules.update', 'Trainingsmodul bearbeiten', 'modules', 'active_member', NULL), + ('modules.delete', 'Trainingsmodul löschen', 'modules', 'active_member', NULL), + ('framework.read', 'Rahmenprogramme lesen', 'framework', 'active_member', NULL), + ('framework.create', 'Rahmenprogramm anlegen', 'framework', 'active_member', 'training_programs'), + ('framework.update', 'Rahmenprogramm bearbeiten', 'framework', 'active_member', NULL), + ('framework.delete', 'Rahmenprogramm löschen', 'framework', 'active_member', NULL), + ('plan_templates.read', 'Planungsvorlagen lesen', 'planning', 'active_member', NULL), + ('plan_templates.manage', 'Planungsvorlagen verwalten', 'planning', 'active_member', NULL), + ('progression.read', 'Progressionspfade lesen', 'progression', 'active_member', NULL), + ('progression.manage', 'Progressionspfade verwalten', 'progression', 'active_member', NULL), + ('planning.calendar.read', 'Planungskalender lesen', 'planning', 'active_member', NULL), + ('planning.units.create', 'Trainingseinheit anlegen', 'planning', 'active_member', 'training_units'), + ('planning.units.update', 'Trainingseinheit bearbeiten', 'planning', 'active_member', NULL), + ('planning.units.delete', 'Trainingseinheit löschen', 'planning', 'active_member', NULL), + ('planning.units.run', 'Training durchführen', 'planning', 'active_member', NULL), + ('planning.coach.execute', 'Coach ausführen', 'planning', 'active_member', NULL), + ('planning.ai.suggest', 'Planungs-KI Suggest', 'planning', 'active_member', 'ai_calls'), + ('planning.ai.progression_path', 'Planungs-KI Progressionspfad', 'planning', 'active_member', 'ai_calls'), + ('skills.catalog.read', 'Fähigkeitenkatalog', 'skills', 'active_member', NULL), + ('skills.discovery.read', 'Fähigkeiten-Discovery', 'skills', 'active_member', NULL), + ('skill_profiles.read', 'Skill-Profile lesen', 'skills', 'active_member', NULL), + ('governance.content_report.create', 'Inhalt melden', 'governance', 'active_member', NULL), + ('governance.content_report.review', 'Meldungen prüfen', 'governance', 'active_member', NULL), + ('platform.admin.access', 'Plattform-Admin-Bereich', 'platform', 'platform_admin', NULL), + ('platform.users.manage', 'Nutzer verwalten', 'platform', 'platform_admin', NULL), + ('platform.catalogs.manage', 'Kataloge verwalten', 'platform', 'platform_admin', NULL), + ('platform.maturity_models.manage', 'Reifegradmodelle', 'platform', 'platform_admin', NULL), + ('platform.wiki_import.execute', 'Wiki-Import', 'platform', 'platform_admin', 'wiki_import'), + ('platform.ai_prompts.manage', 'KI-Prompts verwalten', 'platform', 'platform_admin', NULL), + ('platform.exercise_enrichment.execute', 'Übungs-Anreicherung KI', 'platform', 'platform_admin', 'ai_calls'), + ('platform.user_content.moderate', 'Nutzer-Inhalte moderieren', 'platform', 'platform_admin', NULL), + ('platform.legal_documents.manage', 'Rechtstexte verwalten', 'platform', 'platform_admin', NULL), + ('platform.media_storage.manage', 'Medienspeicher verwalten', 'platform', 'platform_admin', NULL), + ('platform.club_creation.approve', 'Vereinsgründung freigeben', 'platform', 'platform_admin', NULL) +ON CONFLICT (id) DO NOTHING; + +-- ── Vereinsrollen-Grants (§6 — nur eingeschränkte Capabilities) ───────────── +-- Konvention: keine Grant-Zeile = alle aktiven Mitglieder (min_account_state reicht). + +INSERT INTO club_role_capability_grants (role_code, capability_id) +SELECT r.role_code, c.id +FROM (VALUES + ('club_admin', 'org.structure.manage'), + ('division_lead', 'org.structure.manage'), + ('club_admin', 'org.members.manage'), + ('club_admin', 'org.join_request.review'), + ('club_admin', 'org.inbox.read'), + ('club_admin', 'exercises.create'), + ('trainer', 'exercises.create'), + ('content_editor', 'exercises.create'), + ('division_lead', 'exercises.create'), + ('club_admin', 'exercises.update'), + ('trainer', 'exercises.update'), + ('content_editor', 'exercises.update'), + ('division_lead', 'exercises.update'), + ('club_admin', 'exercises.delete'), + ('club_admin', 'exercises.bulk_metadata'), + ('content_editor', 'exercises.bulk_metadata'), + ('club_admin', 'exercises.ai.suggest'), + ('trainer', 'exercises.ai.suggest'), + ('content_editor', 'exercises.ai.suggest'), + ('division_lead', 'exercises.ai.suggest'), + ('club_admin', 'exercises.ai.regenerate'), + ('trainer', 'exercises.ai.regenerate'), + ('content_editor', 'exercises.ai.regenerate'), + ('division_lead', 'exercises.ai.regenerate'), + ('club_admin', 'exercises.media.upload'), + ('trainer', 'exercises.media.upload'), + ('content_editor', 'exercises.media.upload'), + ('club_admin', 'exercises.variants.manage'), + ('trainer', 'exercises.variants.manage'), + ('content_editor', 'exercises.variants.manage'), + ('club_admin', 'media.library.upload'), + ('trainer', 'media.library.upload'), + ('content_editor', 'media.library.upload'), + ('club_admin', 'media.library.update'), + ('trainer', 'media.library.update'), + ('content_editor', 'media.library.update'), + ('club_admin', 'media.library.lifecycle'), + ('trainer', 'media.library.lifecycle'), + ('club_admin', 'media.rights.declare'), + ('trainer', 'media.rights.declare'), + ('club_admin', 'modules.create'), + ('trainer', 'modules.create'), + ('content_editor', 'modules.create'), + ('club_admin', 'modules.update'), + ('trainer', 'modules.update'), + ('content_editor', 'modules.update'), + ('club_admin', 'modules.delete'), + ('club_admin', 'framework.create'), + ('trainer', 'framework.create'), + ('club_admin', 'framework.update'), + ('trainer', 'framework.update'), + ('club_admin', 'framework.delete'), + ('club_admin', 'plan_templates.manage'), + ('trainer', 'plan_templates.manage'), + ('club_admin', 'progression.manage'), + ('trainer', 'progression.manage'), + ('content_editor', 'progression.manage'), + ('club_admin', 'planning.units.create'), + ('trainer', 'planning.units.create'), + ('division_lead', 'planning.units.create'), + ('club_admin', 'planning.units.update'), + ('trainer', 'planning.units.update'), + ('division_lead', 'planning.units.update'), + ('club_admin', 'planning.units.delete'), + ('trainer', 'planning.units.delete'), + ('club_admin', 'planning.units.run'), + ('trainer', 'planning.units.run'), + ('division_lead', 'planning.units.run'), + ('club_admin', 'planning.coach.execute'), + ('trainer', 'planning.coach.execute'), + ('club_admin', 'planning.ai.suggest'), + ('trainer', 'planning.ai.suggest'), + ('division_lead', 'planning.ai.suggest'), + ('club_admin', 'planning.ai.progression_path'), + ('trainer', 'planning.ai.progression_path'), + ('division_lead', 'planning.ai.progression_path'), + ('club_admin', 'skills.discovery.read'), + ('trainer', 'skills.discovery.read'), + ('content_editor', 'skills.discovery.read'), + ('club_admin', 'governance.content_report.review') +) AS r(role_code, cap_id) +JOIN capabilities c ON c.id = r.cap_id +ON CONFLICT DO NOTHING; + +-- org.club.update: club_admin (zusätzlich zu platform_admin via Bypass) +INSERT INTO club_role_capability_grants (role_code, capability_id) +VALUES ('club_admin', 'org.club.update') +ON CONFLICT DO NOTHING; + +-- ── Portal-Rollen ─────────────────────────────────────────────────────────── +INSERT INTO portal_role_capability_grants (portal_role, capability_id) +SELECT 'admin', id FROM capabilities WHERE id = 'platform.admin.access' +ON CONFLICT DO NOTHING; + +INSERT INTO portal_role_capability_grants (portal_role, capability_id) +SELECT 'superadmin', id FROM capabilities WHERE domain = 'platform' +ON CONFLICT DO NOTHING; diff --git a/backend/migrations/080_club_creation_requests.sql b/backend/migrations/080_club_creation_requests.sql new file mode 100644 index 0000000..7b68baf --- /dev/null +++ b/backend/migrations/080_club_creation_requests.sql @@ -0,0 +1,41 @@ +-- Migration 080: Antrag auf Vereinsgründung (M7) +-- Nutzer verified_pending_club stellt Antrag; Plattform-Admin legt Verein + Abo an. + +CREATE TABLE IF NOT EXISTS club_creation_requests ( + id SERIAL PRIMARY KEY, + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + proposed_name VARCHAR(200) NOT NULL, + proposed_abbreviation VARCHAR(50), + proposed_description TEXT, + message TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')), + decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + decided_at TIMESTAMP, + created_club_id INT REFERENCES clubs(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_club_creation_requests_pending + ON club_creation_requests (profile_id) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_club_creation_requests_status + ON club_creation_requests (status, created_at); + +CREATE INDEX IF NOT EXISTS idx_club_creation_requests_profile + ON club_creation_requests (profile_id); + +DROP TRIGGER IF EXISTS club_creation_requests_update ON club_creation_requests; +CREATE TRIGGER club_creation_requests_update + BEFORE UPDATE ON club_creation_requests + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); + +-- Capabilities (CAPABILITY_CATALOG.v1.md — club.creation_request.*) +INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) +VALUES + ('club.creation_request.create', 'Vereinsgründung beantragen', 'club', 'verified_pending_club', NULL), + ('club.creation_request.read_own', 'Eigene Gründungsanträge', 'club', 'verified_pending_club', NULL), + ('club.creation_request.withdraw', 'Gründungsantrag zurückziehen', 'club', 'verified_pending_club', NULL) +ON CONFLICT (id) DO NOTHING; diff --git a/backend/migrations/081_club_creation_request_superseded.sql b/backend/migrations/081_club_creation_request_superseded.sql new file mode 100644 index 0000000..f78606c --- /dev/null +++ b/backend/migrations/081_club_creation_request_superseded.sql @@ -0,0 +1,13 @@ +-- Migration 081: Status superseded wenn freigegebener Verein gelöscht wurde + +ALTER TABLE club_creation_requests + DROP CONSTRAINT IF EXISTS club_creation_requests_status_check; + +ALTER TABLE club_creation_requests + ADD CONSTRAINT club_creation_requests_status_check + CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn', 'superseded')); + +-- Bestehende Drift: approved ohne Verein (ON DELETE SET NULL auf created_club_id) +UPDATE club_creation_requests +SET status = 'superseded', updated_at = NOW() +WHERE status = 'approved' AND created_club_id IS NULL; diff --git a/backend/migrations/082_platform_club_feature_exemptions.sql b/backend/migrations/082_platform_club_feature_exemptions.sql new file mode 100644 index 0000000..f3ede5f --- /dev/null +++ b/backend/migrations/082_platform_club_feature_exemptions.sql @@ -0,0 +1,36 @@ +-- Migration 082: Plattform-/Profil-Ausnahmen vom Vereins-Kontingent (M5+) +-- Superadmin & konfigurierbare Rollen/Profile verbrauchen kein club_feature_usage. + +CREATE TABLE IF NOT EXISTS platform_role_club_feature_exemptions ( + id SERIAL PRIMARY KEY, + portal_role TEXT NOT NULL, + feature_id TEXT REFERENCES features(id) ON DELETE CASCADE, + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_role_club_feat_exempt + ON platform_role_club_feature_exemptions (portal_role, COALESCE(feature_id, '*')); + +CREATE TABLE IF NOT EXISTS profile_club_feature_exemptions ( + id SERIAL PRIMARY KEY, + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + feature_id TEXT REFERENCES features(id) ON DELETE CASCADE, + reason TEXT, + set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_profile_club_feat_exempt + ON profile_club_feature_exemptions (profile_id, COALESCE(feature_id, '*')); + +CREATE INDEX IF NOT EXISTS idx_profile_club_feat_exempt_profile + ON profile_club_feature_exemptions (profile_id); + +-- Superadmin: alle Vereins-Features ohne Kontingent-Verbrauch +INSERT INTO platform_role_club_feature_exemptions (portal_role, feature_id, note) +SELECT 'superadmin', NULL, 'Plattform-Administrator: kein Vereins-Kontingent' +WHERE NOT EXISTS ( + SELECT 1 FROM platform_role_club_feature_exemptions + WHERE portal_role = 'superadmin' AND feature_id IS NULL +); diff --git a/backend/migrations/083_capability_quota_bypass.sql b/backend/migrations/083_capability_quota_bypass.sql new file mode 100644 index 0000000..c2db8a6 --- /dev/null +++ b/backend/migrations/083_capability_quota_bypass.sql @@ -0,0 +1,103 @@ +-- Migration 083: Vereins-Kontingent-Bypass über Capability-System (kein Parallel-Schema) +-- Ersetzt platform_role_club_feature_exemptions / profile_club_feature_exemptions aus 082. + +-- Einzelprofil-Grants (ergänzt portal_role_capability_grants) +CREATE TABLE IF NOT EXISTS profile_capability_grants ( + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE, + reason TEXT, + granted_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (profile_id, capability_id) +); + +CREATE INDEX IF NOT EXISTS idx_profile_capability_grants_cap + ON profile_capability_grants(capability_id); + +-- Bypass-Capabilities (CAPABILITY_CATALOG — konfigurierbar via portal/profile grants) +INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) +VALUES + ( + 'platform.club_quota.bypass', + 'Vereins-Kontingent umgehen (alle Features)', + 'platform', + 'platform_admin', + NULL + ) +ON CONFLICT (id) DO NOTHING; + +-- Superadmin: alle Plattform-Capabilities inkl. bypass (079-Seed deckt domain=platform ab) +INSERT INTO portal_role_capability_grants (portal_role, capability_id) +SELECT 'superadmin', 'platform.club_quota.bypass' +WHERE NOT EXISTS ( + SELECT 1 FROM portal_role_capability_grants + WHERE portal_role = 'superadmin' AND capability_id = 'platform.club_quota.bypass' +); + +-- ── Daten aus 082 übernehmen (falls vorhanden) ───────────────────────────── +DO $migrate082$ +DECLARE + r RECORD; + cap_id TEXT; +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'platform_role_club_feature_exemptions' + ) THEN + RETURN; + END IF; + + FOR r IN + SELECT portal_role, feature_id, note + FROM platform_role_club_feature_exemptions + LOOP + IF r.feature_id IS NULL THEN + cap_id := 'platform.club_quota.bypass'; + ELSE + cap_id := 'platform.club_quota.bypass.' || r.feature_id; + INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) + VALUES ( + cap_id, + 'Vereins-Kontingent umgehen: ' || r.feature_id, + 'quota_bypass', + 'active_member', + r.feature_id + ) + ON CONFLICT (id) DO NOTHING; + END IF; + + INSERT INTO portal_role_capability_grants (portal_role, capability_id) + VALUES (lower(trim(r.portal_role)), cap_id) + ON CONFLICT DO NOTHING; + END LOOP; + + FOR r IN + SELECT profile_id, feature_id, reason, set_by_profile_id + FROM profile_club_feature_exemptions + LOOP + IF r.feature_id IS NULL THEN + cap_id := 'platform.club_quota.bypass'; + ELSE + cap_id := 'platform.club_quota.bypass.' || r.feature_id; + INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) + VALUES ( + cap_id, + 'Vereins-Kontingent umgehen: ' || r.feature_id, + 'quota_bypass', + 'active_member', + r.feature_id + ) + ON CONFLICT (id) DO NOTHING; + END IF; + + INSERT INTO profile_capability_grants ( + profile_id, capability_id, reason, granted_by_profile_id + ) + VALUES (r.profile_id, cap_id, r.reason, r.set_by_profile_id) + ON CONFLICT DO NOTHING; + END LOOP; + + DROP TABLE IF EXISTS profile_club_feature_exemptions; + DROP TABLE IF EXISTS platform_role_club_feature_exemptions; +END +$migrate082$; diff --git a/backend/migrations/084_rights_registry_module.sql b/backend/migrations/084_rights_registry_module.sql new file mode 100644 index 0000000..24c26e2 --- /dev/null +++ b/backend/migrations/084_rights_registry_module.sql @@ -0,0 +1,15 @@ +-- Migration 084: Modul-Registrierung für Rechte & Kontingente (Registry-first) +-- capabilities/features mit module=NULL = Legacy-Katalog-Seed (nicht in Admin-Matrix). +-- module IS NOT NULL = vom Modul bei Implementierung registriert. + +ALTER TABLE capabilities + ADD COLUMN IF NOT EXISTS module TEXT; + +ALTER TABLE features + ADD COLUMN IF NOT EXISTS module TEXT; + +CREATE INDEX IF NOT EXISTS idx_capabilities_module + ON capabilities(module) WHERE module IS NOT NULL AND active = true; + +CREATE INDEX IF NOT EXISTS idx_features_module + ON features(module) WHERE module IS NOT NULL AND active = true; diff --git a/backend/migrations/085_ai_prompt_exercise_planning_context.sql b/backend/migrations/085_ai_prompt_exercise_planning_context.sql new file mode 100644 index 0000000..0811729 --- /dev/null +++ b/backend/migrations/085_ai_prompt_exercise_planning_context.sql @@ -0,0 +1,181 @@ +-- Migration 085: Planungskontext in Übungs-KI-Prompts (Phase D) +-- Platzhalter: {{planning_context_json}}, {{#has_planning_context}} … {{/has_planning_context}} + +UPDATE ai_prompts +SET template = $s$Du bist Assistent fuer Kampfsport-Trainer. +Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene. + +Anforderungen: +- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile) +- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus? +- Sachlich, auf Deutsch + +Uebung: {{exercise_title}} +Fokuskontext: {{exercise_focus_area}} +Ziel (Fliesstext, kann HTML sein): {{exercise_goal}} +Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}} +{{#has_planning_context}} +Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad): +{{planning_context_json}} +{{/has_planning_context}} + +Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$, + default_template = $s$Du bist Assistent fuer Kampfsport-Trainer. +Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene. + +Anforderungen: +- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile) +- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus? +- Sachlich, auf Deutsch + +Uebung: {{exercise_title}} +Fokuskontext: {{exercise_focus_area}} +Ziel (Fliesstext, kann HTML sein): {{exercise_goal}} +Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}} +{{#has_planning_context}} +Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad): +{{planning_context_json}} +{{/has_planning_context}} + +Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$ +WHERE slug = 'exercise_summary'; + +UPDATE ai_prompts +SET template = $j$Du bist Assistent fuer Kampfsport-Trainer. +Ordne diese Uebung dem globalen Skill-Katalog zu. + +Daten zur Uebung: +Titel: {{exercise_title}} +Fokuskontext (optional): {{exercise_focus_area}} +Ziel (gekuerzt_plain): {{exercise_goal}} +Durchfuehrung (gekuerzt_plain): {{exercise_execution}} +{{#has_planning_context}} +Planungskontext (JSON): +{{planning_context_json}} +{{/has_planning_context}} + +Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden): +{{skills_catalog}} + +Waehle hoechstens 5 passende Skills. Für jede Faehigkeit: +- skill_id: ganze Zahl aus der Liste +- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung +- target_level: derselbe Wertvorrat +- intensity: eines von niedrig, mittel, hoch +- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen + +Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences. + +Beispielformat: +[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}] + +Wenn nichts gut passt, antworte mit [].$j$, + default_template = $j$Du bist Assistent fuer Kampfsport-Trainer. +Ordne diese Uebung dem globalen Skill-Katalog zu. + +Daten zur Uebung: +Titel: {{exercise_title}} +Fokuskontext (optional): {{exercise_focus_area}} +Ziel (gekuerzt_plain): {{exercise_goal}} +Durchfuehrung (gekuerzt_plain): {{exercise_execution}} +{{#has_planning_context}} +Planungskontext (JSON): +{{planning_context_json}} +{{/has_planning_context}} + +Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden): +{{skills_catalog}} + +Waehle hoechstens 5 passende Skills. Für jede Faehigkeit: +- skill_id: ganze Zahl aus der Liste +- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung +- target_level: derselbe Wertvorrat +- intensity: eines von niedrig, mittel, hoch +- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen + +Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences. + +Beispielformat: +[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}] + +Wenn nichts gut passt, antworte mit [].$j$ +WHERE slug = 'exercise_skill_suggestions'; + +UPDATE ai_prompts +SET template = $t$Du bist Assistent fuer Kampfsport-Trainer. +Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen. +Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing. + +Stil: +- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte) +- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels) +- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze) +- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String +- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze + +Format (HTML fuer Rich-Text-Editor): +- Erlaubt:

,