From c294c27de8182868309e07d41a8c792545fbab4c Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 6 Jun 2026 20:44:51 +0200 Subject: [PATCH 01/30] Update Access Layer and Governance Documentation - Enhanced the ACCESS_LAYER_AND_GOVERNANCE_PLAN.md with new specifications for capability documentation and community features. - Added references to new documents detailing capability IDs and club membership features. - Updated MULTI_TENANCY_RBAC_ARCHITECTURE.md to include links to the new specifications. - Marked certain features as deprecated in backend/auth.py, indicating migration paths for club feature access. - Incremented DB_SCHEMA_VERSION to 20260606078 in version.py to reflect recent changes. --- .../ACCESS_LAYER_AND_GOVERNANCE_PLAN.md | 10 +- .../docs/technical/CAPABILITY_CATALOG.v1.md | 328 +++++++++++++ .../CLUB_MEMBERSHIP_AND_FEATURES.v1.md | 464 ++++++++++++++++++ .../MULTI_TENANCY_RBAC_ARCHITECTURE.md | 4 +- backend/auth.py | 6 + backend/club_features.py | 336 +++++++++++++ .../078_club_features_and_plans.sql | 264 ++++++++++ backend/tests/test_club_features.py | 27 + backend/version.py | 3 +- 9 files changed, 1437 insertions(+), 5 deletions(-) create mode 100644 .claude/docs/technical/CAPABILITY_CATALOG.v1.md create mode 100644 .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md create mode 100644 backend/club_features.py create mode 100644 backend/migrations/078_club_features_and_plans.sql create mode 100644 backend/tests/test_club_features.py 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..fb4f1bf --- /dev/null +++ b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md @@ -0,0 +1,328 @@ +# Capability-Katalog Shinkan v1 + +**Status:** Konzept (verbindliche Zieldefinition vor Implementierung) +**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` + +--- + +## 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`, `account.settings` | +| `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** → zuerst Capability-ID hier eintragen, dann Endpoint. +2. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback. +3. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?). +4. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas). + +--- + +## 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..ffced68 --- /dev/null +++ b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md @@ -0,0 +1,464 @@ +# Vereins-Membership & Feature-System Shinkan v1 + +**Status:** Konzept (Schema- und Enforcement-Zielbild vor Implementierung) +**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` + +--- + +## 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. increment_club_feature_usage(club_id, feature_id) # nur bei INSERT / KI-Execute +9. optional: log club_feature_usage_events (profile_id) +``` + +### 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 | + +--- + +## 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? (Capability-Doc) +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/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/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/club_features.py b/backend/club_features.py new file mode 100644 index 0000000..8ac2f77 --- /dev/null +++ b/backend/club_features.py @@ -0,0 +1,336 @@ +""" +Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id). + +Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md +Enforcement in Routern folgt in M2+ (Phase 2–4); M1 liefert Schema + Prüf-Helfer. + +Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) — nicht für Shinkan-Limits nutzen. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +from db import get_db, get_cursor + + +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 _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) + + 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 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), + ) + + c.commit() + + if conn is not None: + _run(conn) + else: + with get_db() as c: + _run(c) + + +def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]: + """Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (für API/UI).""" + 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, cur.connection) + 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"), + } + ) + return {"club_id": club_id, "plan_id": plan_id, "features": features_out} 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..c3e556d --- /dev/null +++ b/backend/migrations/078_club_features_and_plans.sql @@ -0,0 +1,264 @@ +-- 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 + ALTER TABLE features RENAME TO features_legacy_001; + 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 + ALTER TABLE tier_limits RENAME TO tier_limits_legacy_001; + 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 + ALTER TABLE user_feature_usage RENAME TO user_feature_usage_legacy_001; + 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/tests/test_club_features.py b/backend/tests/test_club_features.py new file mode 100644 index 0000000..9a4188b --- /dev/null +++ b/backend/tests/test_club_features.py @@ -0,0 +1,27 @@ +"""Unit-Tests für club_features (ohne DB).""" +from datetime import datetime, timezone + +from club_features import _calculate_next_reset, _normalize_limit + + +def test_normalize_limit_none_and_negative(): + assert _normalize_limit(None) is None + assert _normalize_limit(-1) is None + assert _normalize_limit(0) == 0 + assert _normalize_limit(50) == 50 + + +def test_calculate_next_reset_never(): + assert _calculate_next_reset("never") is None + + +def test_calculate_next_reset_monthly_december(): + ref = datetime(2026, 12, 15, 12, 0, tzinfo=timezone.utc) + nxt = _calculate_next_reset("monthly", now=ref) + assert nxt == datetime(2027, 1, 1, tzinfo=timezone.utc) + + +def test_calculate_next_reset_monthly_mid_year(): + ref = datetime(2026, 6, 6, 12, 0, tzinfo=timezone.utc) + nxt = _calculate_next_reset("monthly", now=ref) + assert nxt == datetime(2026, 7, 1, tzinfo=timezone.utc) diff --git a/backend/version.py b/backend/version.py index 08a140f..2fbfe81 100644 --- a/backend/version.py +++ b/backend/version.py @@ -2,7 +2,7 @@ APP_VERSION = "0.8.190" BUILD_DATE = "2026-05-23" -DB_SCHEMA_VERSION = "20260531077" +DB_SCHEMA_VERSION = "20260606078" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -13,6 +13,7 @@ MODULE_VERSIONS = { "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) "admin_users": "1.0.0", # GET /api/admin/users + "club_features": "1.0.0", # Migration 078: features v9c + club_plans/subscriptions; check_club_feature_access "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen) "media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins) -- 2.43.0 From 7cfbca40bba7f7c6aa83b956928f38759ba84061 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 6 Jun 2026 21:00:42 +0200 Subject: [PATCH 02/30] Implement Club Feature Access Probing and Inventory Count - Introduced `probe_club_feature_access` to check club feature limits and log access attempts without blocking by default. - Added `_live_inventory_count` function to retrieve current counts for specific features, enhancing feature limit management. - Updated various endpoints to utilize the new probing functionality, ensuring compliance with club feature access rules. - Incremented version to 1.1.0 in version.py to reflect these enhancements in club feature management. --- backend/club_feature_logger.py | 74 ++++++++++ backend/club_features.py | 147 ++++++++++++++++++- backend/routers/exercises.py | 42 +++++- backend/routers/planning_exercise_suggest.py | 21 +++ backend/tests/test_club_feature_logger.py | 62 ++++++++ backend/version.py | 2 +- 6 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 backend/club_feature_logger.py create mode 100644 backend/tests/test_club_feature_logger.py 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 index 8ac2f77..0bda5df 100644 --- a/backend/club_features.py +++ b/backend/club_features.py @@ -2,17 +2,29 @@ Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id). Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md -Enforcement in Routern folgt in M2+ (Phase 2–4); M1 liefert Schema + Prüf-Helfer. +Phase 2 (M2): probe_club_feature_access — JSON-Log, kein HTTP-Block. +Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 — HTTP 403 + increment. 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 +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'.""" @@ -113,6 +125,61 @@ def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) - 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 @@ -210,6 +277,12 @@ def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]: 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, @@ -244,6 +317,76 @@ def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]: } +def club_feature_enforcement_enabled() -> bool: + """Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1).""" + return os.getenv("CLUB_FEATURE_ENFORCE", "0").strip() == "1" + + +def probe_club_feature_access( + *, + feature_id: str, + action: str, + club_id: Optional[int] = None, + profile_id: Optional[int] = None, + endpoint: Optional[str] = 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": True, + "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="probe", + ) + return access + + if conn is not None: + access = check_club_feature_access(club_id, feature_id, conn=conn) + else: + with get_db() as c: + access = check_club_feature_access(club_id, feature_id, conn=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 increment_club_feature_usage( club_id: int, feature_id: str, diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index e6cb706..879296e 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -37,6 +37,7 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar from media_legal_hold import assert_not_under_legal_hold from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext from ai_prompt_job import run_exercise_form_ai_suggestion +from club_features import probe_club_feature_access, resolve_club_id_for_probe from exercise_rich_text import ( RICH_HTML_EXERCISE_FIELDS, @@ -2317,7 +2318,13 @@ def exercise_ai_suggest_endpoint( KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern. OPENROUTER_API_KEY erforderlich. """ - _ = tenant.profile_id + probe_club_feature_access( + feature_id="ai_calls", + action="suggest", + club_id=resolve_club_id_for_probe(tenant), + profile_id=tenant.profile_id, + endpoint="POST /exercises/ai/suggest", + ) with get_db() as conn: cur = get_cursor(conn) payload = run_exercise_form_ai_suggestion( @@ -2337,6 +2344,13 @@ def exercise_ai_regenerate_endpoint( tenant: TenantContext = Depends(get_tenant_context), ): """Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT).""" + probe_club_feature_access( + feature_id="ai_calls", + action="regenerate", + club_id=resolve_club_id_for_probe(tenant), + profile_id=tenant.profile_id, + endpoint="POST /exercises/{id}/ai/regenerate", + ) want_summary = "summary" in body.regenerate want_skills = "skills" in body.regenerate want_instructions = "instructions" in body.regenerate @@ -2421,6 +2435,15 @@ def create_exercise( if body.visibility == "club" and club_id is None: club_id = tenant.effective_club_id + if club_id is not None: + probe_club_feature_access( + feature_id="exercises", + action="create", + club_id=int(club_id), + profile_id=profile_id, + endpoint="POST /exercises", + ) + # §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich create_ids: set[int] = set() for fld in sorted(RICH_HTML_EXERCISE_FIELDS): @@ -3214,6 +3237,23 @@ async def upload_exercise_media( cur = get_cursor(conn) _assert_can_edit_exercise(cur, exercise_id, tenant) + if has_file: + cur.execute( + "SELECT club_id FROM exercises WHERE id = %s", + (exercise_id,), + ) + ex_club = cur.fetchone() + media_club_id = ex_club.get("club_id") if ex_club else None + if media_club_id is not None: + probe_club_feature_access( + feature_id="exercise_media", + action="upload", + club_id=int(media_club_id), + profile_id=profile_id, + endpoint="POST /exercises/{id}/media", + conn=conn, + ) + if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA: raise HTTPException( status_code=400, diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index d1f33af..229219e 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -7,6 +7,7 @@ from db import get_db, get_cursor from tenant_context import TenantContext, get_tenant_context from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path +from club_features import probe_club_feature_access, resolve_club_id_for_probe router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"]) @@ -16,6 +17,14 @@ def post_planning_exercise_suggest( body: PlanningExerciseSuggestRequest, tenant: TenantContext = Depends(get_tenant_context), ): + if body.include_llm_intent or body.include_llm_rank: + probe_club_feature_access( + feature_id="ai_calls", + action="planning_suggest", + club_id=resolve_club_id_for_probe(tenant), + profile_id=tenant.profile_id, + endpoint="POST /planning/exercise-suggest", + ) with get_db() as conn: cur = get_cursor(conn) return suggest_planning_exercises(cur, tenant=tenant, body=body) @@ -26,6 +35,18 @@ def post_progression_path_suggest( body: ProgressionPathSuggestRequest, tenant: TenantContext = Depends(get_tenant_context), ): + if ( + body.include_llm_intent + or body.include_llm_path_qa + or body.include_ai_gap_fill + ): + probe_club_feature_access( + feature_id="ai_calls", + action="progression_path_suggest", + club_id=resolve_club_id_for_probe(tenant), + profile_id=tenant.profile_id, + endpoint="POST /planning/progression-path-suggest", + ) with get_db() as conn: cur = get_cursor(conn) return suggest_progression_path(cur, tenant=tenant, body=body) diff --git a/backend/tests/test_club_feature_logger.py b/backend/tests/test_club_feature_logger.py new file mode 100644 index 0000000..1653cc9 --- /dev/null +++ b/backend/tests/test_club_feature_logger.py @@ -0,0 +1,62 @@ +"""Tests für club_feature_logger und probe (ohne DB).""" +import json + +from club_feature_logger import feature_usage_logger, log_club_feature_usage +from club_features import club_feature_enforcement_enabled, probe_club_feature_access + + +def test_log_club_feature_usage_json(monkeypatch): + captured = [] + monkeypatch.setattr(feature_usage_logger, "info", lambda msg: captured.append(msg)) + access = { + "allowed": False, + "limit": 0, + "used": 3, + "remaining": 0, + "reason": "feature_disabled", + "plan_id": "free", + } + log_club_feature_usage( + club_id=12, + profile_id=7, + feature_id="ai_calls", + action="suggest", + access=access, + endpoint="POST /exercises/ai/suggest", + ) + assert captured + payload = json.loads(captured[-1]) + assert payload["club_id"] == 12 + assert payload["profile_id"] == 7 + assert payload["feature"] == "ai_calls" + assert payload["allowed"] is False + assert payload["plan_id"] == "free" + assert payload["phase"] == "probe" + + +def test_probe_no_club_context_logs_without_db(monkeypatch): + logged = [] + + def _capture(**kwargs): + logged.append(kwargs) + + monkeypatch.setattr("club_feature_logger.log_club_feature_usage", _capture) + + access = probe_club_feature_access( + feature_id="ai_calls", + action="suggest", + club_id=None, + profile_id=1, + endpoint="test", + ) + assert access["reason"] == "no_club_context" + assert access["allowed"] is True + assert len(logged) == 1 + assert logged[0]["club_id"] is None + + +def test_club_feature_enforcement_default_off(monkeypatch): + monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False) + assert club_feature_enforcement_enabled() is False + monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1") + assert club_feature_enforcement_enabled() is True diff --git a/backend/version.py b/backend/version.py index 2fbfe81..bab4dec 100644 --- a/backend/version.py +++ b/backend/version.py @@ -13,7 +13,7 @@ MODULE_VERSIONS = { "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) "admin_users": "1.0.0", # GET /api/admin/users - "club_features": "1.0.0", # Migration 078: features v9c + club_plans/subscriptions; check_club_feature_access + "club_features": "1.1.0", # M2: probe_club_feature_access + JSON-Log (Phase 2, non-blocking) "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen) "media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins) -- 2.43.0 From 30dc30c7aa86bf77438fcc2693e85d001e27b3d0 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 6 Jun 2026 21:10:52 +0200 Subject: [PATCH 03/30] Enhance Tenant Context and Access Control Features - Introduced `email_verified` and `account_state` attributes in the `TenantContext` to improve user state management. - Updated the `resolve_tenant_context` function to dynamically fetch `email_verified` status from the database and determine `account_state` based on user roles and memberships. - Implemented `assert_min_account_state` checks across various endpoints to enforce access control based on user account status. - Incremented version to 1.1.0 in version.py to reflect these enhancements in tenant context management and access control. --- backend/account_lifecycle.py | 77 ++++++ backend/capabilities.py | 232 ++++++++++++++++++ backend/capability_logger.py | 64 +++++ backend/club_features.py | 33 ++- backend/entitlements.py | 89 +++++++ backend/main.py | 3 +- backend/migrations/079_capabilities.sql | 211 ++++++++++++++++ backend/routers/exercises.py | 35 +++ backend/routers/me_entitlements.py | 27 ++ backend/routers/planning_exercise_suggest.py | 20 ++ backend/routers/profiles.py | 6 + backend/tenant_context.py | 23 ++ backend/tests/test_access_layer.py | 5 + backend/tests/test_account_capabilities.py | 86 +++++++ backend/tests/test_entitlements.py | 75 ++++++ backend/version.py | 11 +- frontend/src/App.jsx | 13 +- frontend/src/components/FeatureUsageBadge.jsx | 40 +++ .../exercises/ExerciseFormPageRoot.jsx | 22 +- frontend/src/context/EntitlementsContext.jsx | 64 +++++ frontend/src/utils/api.js | 10 + 21 files changed, 1124 insertions(+), 22 deletions(-) create mode 100644 backend/account_lifecycle.py create mode 100644 backend/capabilities.py create mode 100644 backend/capability_logger.py create mode 100644 backend/entitlements.py create mode 100644 backend/migrations/079_capabilities.sql create mode 100644 backend/routers/me_entitlements.py create mode 100644 backend/tests/test_account_capabilities.py create mode 100644 backend/tests/test_entitlements.py create mode 100644 frontend/src/components/FeatureUsageBadge.jsx create mode 100644 frontend/src/context/EntitlementsContext.jsx 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/capabilities.py b/backend/capabilities.py new file mode 100644 index 0000000..83d6f2a --- /dev/null +++ b/backend/capabilities.py @@ -0,0 +1,232 @@ +""" +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: + return os.getenv("CAPABILITY_ENFORCE", "0").strip() == "1" + + +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() + + # 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(): + 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_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_features.py b/backend/club_features.py index 0bda5df..bc1ef25 100644 --- a/backend/club_features.py +++ b/backend/club_features.py @@ -446,8 +446,9 @@ def increment_club_feature_usage( _run(c) -def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]: - """Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (für API/UI).""" +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( """ @@ -461,7 +462,7 @@ def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]: features_out = [] for row in rows: fid = row["id"] - access = _check_club_impl(club_id, fid, cur.connection) + access = _check_club_impl(club_id, fid, db_conn) features_out.append( { "id": fid, @@ -474,6 +475,32 @@ def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]: "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/entitlements.py b/backend/entitlements.py new file mode 100644 index 0000000..60cced8 --- /dev/null +++ b/backend/entitlements.py @@ -0,0 +1,89 @@ +""" +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_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(): + 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"), + } + + 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/main.py b/backend/main.py index 93b1e36..81884bf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -193,7 +193,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, admin_users, admin_user_content, 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) @@ -204,6 +204,7 @@ app.include_router(club_memberships.router) app.include_router(club_join_requests.router) app.include_router(admin_users.router) app.include_router(admin_user_content.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/079_capabilities.sql b/backend/migrations/079_capabilities.sql new file mode 100644 index 0000000..4c6ae24 --- /dev/null +++ b/backend/migrations/079_capabilities.sql @@ -0,0 +1,211 @@ +-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1) +-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py). + +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 REFERENCES features(id) ON DELETE SET NULL, + 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.cap_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/routers/exercises.py b/backend/routers/exercises.py index 879296e..5b63e66 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -37,6 +37,8 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar from media_legal_hold import assert_not_under_legal_hold from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext from ai_prompt_job import run_exercise_form_ai_suggestion +from account_lifecycle import assert_min_account_state +from capabilities import probe_capability from club_features import probe_club_feature_access, resolve_club_id_for_probe from exercise_rich_text import ( @@ -2318,6 +2320,14 @@ def exercise_ai_suggest_endpoint( KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern. OPENROUTER_API_KEY erforderlich. """ + assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/ai/suggest") + probe_capability( + tenant, + "exercises.ai.suggest", + action="suggest", + club_id=resolve_club_id_for_probe(tenant), + endpoint="POST /exercises/ai/suggest", + ) probe_club_feature_access( feature_id="ai_calls", action="suggest", @@ -2344,6 +2354,14 @@ def exercise_ai_regenerate_endpoint( tenant: TenantContext = Depends(get_tenant_context), ): """Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT).""" + assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/ai/regenerate") + probe_capability( + tenant, + "exercises.ai.regenerate", + action="regenerate", + club_id=resolve_club_id_for_probe(tenant), + endpoint="POST /exercises/{id}/ai/regenerate", + ) probe_club_feature_access( feature_id="ai_calls", action="regenerate", @@ -2436,6 +2454,14 @@ def create_exercise( club_id = tenant.effective_club_id if club_id is not None: + assert_min_account_state(tenant, "active_member", endpoint="POST /exercises") + probe_capability( + tenant, + "exercises.create", + action="create", + club_id=int(club_id), + endpoint="POST /exercises", + ) probe_club_feature_access( feature_id="exercises", action="create", @@ -3245,6 +3271,15 @@ async def upload_exercise_media( ex_club = cur.fetchone() media_club_id = ex_club.get("club_id") if ex_club else None if media_club_id is not None: + assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/media") + probe_capability( + tenant, + "exercises.media.upload", + action="upload", + club_id=int(media_club_id), + endpoint="POST /exercises/{id}/media", + conn=conn, + ) probe_club_feature_access( feature_id="exercise_media", action="upload", diff --git a/backend/routers/me_entitlements.py b/backend/routers/me_entitlements.py new file mode 100644 index 0000000..90dd4a9 --- /dev/null +++ b/backend/routers/me_entitlements.py @@ -0,0 +1,27 @@ +""" +GET /api/me/entitlements — effektive Capabilities + Feature-Kontingente (M4). +""" +from typing import Optional + +from fastapi import APIRouter, Depends, Query + +from db import get_db, get_cursor +from entitlements import build_me_entitlements +from tenant_context import TenantContext, get_tenant_context + +router = APIRouter(prefix="/api", tags=["entitlements"]) + + +@router.get("/me/entitlements") +def get_me_entitlements( + tenant: TenantContext = Depends(get_tenant_context), + club_id: Optional[int] = Query(default=None, ge=1, description="Verein (Default: effective_club_id)"), +): + """ + Effektive Rechte für Frontend: Account-Status, Capabilities, Feature-Limits. + + Spez: CAPABILITY_CATALOG.v1.md §7.1 + """ + with get_db() as conn: + cur = get_cursor(conn) + return build_me_entitlements(cur, tenant, club_id=club_id) diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index 229219e..9bcba18 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -7,6 +7,8 @@ from db import get_db, get_cursor from tenant_context import TenantContext, get_tenant_context from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path +from account_lifecycle import assert_min_account_state +from capabilities import probe_capability from club_features import probe_club_feature_access, resolve_club_id_for_probe router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"]) @@ -18,6 +20,14 @@ def post_planning_exercise_suggest( tenant: TenantContext = Depends(get_tenant_context), ): if body.include_llm_intent or body.include_llm_rank: + assert_min_account_state(tenant, "active_member", endpoint="POST /planning/exercise-suggest") + probe_capability( + tenant, + "planning.ai.suggest", + action="planning_suggest", + club_id=resolve_club_id_for_probe(tenant), + endpoint="POST /planning/exercise-suggest", + ) probe_club_feature_access( feature_id="ai_calls", action="planning_suggest", @@ -40,6 +50,16 @@ def post_progression_path_suggest( or body.include_llm_path_qa or body.include_ai_gap_fill ): + assert_min_account_state( + tenant, "active_member", endpoint="POST /planning/progression-path-suggest" + ) + probe_capability( + tenant, + "planning.ai.progression_path", + action="progression_path_suggest", + club_id=resolve_club_id_for_probe(tenant), + endpoint="POST /planning/progression-path-suggest", + ) probe_club_feature_access( feature_id="ai_calls", action="progression_path_suggest", diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index c554b3e..ad4c950 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -22,6 +22,7 @@ from club_tenancy import ( is_superadmin, memberships_with_roles, ) +from capabilities import club_roles_in_club from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context from models import ProfileCreate, ProfileUpdate @@ -118,6 +119,11 @@ def get_current_profile( invalid_header_policy="ignore", ) data["effective_club_id"] = tenant.effective_club_id + data["account_state"] = tenant.account_state + if tenant.effective_club_id is not None: + data["club_roles"] = club_roles_in_club(tenant, tenant.effective_club_id) + else: + data["club_roles"] = [] return data diff --git a/backend/tenant_context.py b/backend/tenant_context.py index fe76393..175221e 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional from fastapi import Depends, Header, HTTPException from auth import require_auth, require_auth_flexible +from account_lifecycle import resolve_account_state from club_tenancy import is_platform_admin, memberships_with_roles from db import get_db, get_cursor @@ -142,6 +143,8 @@ class TenantContext: effective_club_id: Optional[int] club_ids: frozenset[int] memberships: List[Dict[str, Any]] + email_verified: bool = True + account_state: str = "active_member" def resolve_tenant_context( @@ -153,6 +156,7 @@ def resolve_tenant_context( memberships: Optional[List[Dict[str, Any]]] = None, stored_active_club_id: Optional[int] = None, invalid_header_policy: str = "reject", + email_verified: Optional[bool] = None, ) -> TenantContext: """ Mitgliedschaften: wenn nicht übergeben, lädt ``active_only=True`` aus der DB. @@ -176,6 +180,21 @@ def resolve_tenant_context( club_ids = frozenset(int(r["id"]) for r in membership_rows if r.get("id") is not None) + if email_verified is None: + cur.execute( + "SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s", + (profile_id,), + ) + prof_row = cur.fetchone() + email_verified = bool(prof_row.get("email_verified")) if prof_row else False + else: + email_verified = bool(email_verified) + account_state = resolve_account_state( + email_verified=email_verified, + global_role=role_lc, + has_active_membership=len(club_ids) > 0, + ) + if is_platform_admin(role_lc): if header_cid is not None: if not _club_exists(cur, header_cid): @@ -194,6 +213,8 @@ def resolve_tenant_context( effective_club_id=effective, club_ids=club_ids, memberships=membership_rows, + email_verified=email_verified, + account_state=account_state, ) chosen_header = header_cid @@ -222,6 +243,8 @@ def resolve_tenant_context( effective_club_id=effective, club_ids=club_ids, memberships=membership_rows, + email_verified=email_verified, + account_state=account_state, ) diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index d74fe7a..915c0ee 100644 --- a/backend/tests/test_access_layer.py +++ b/backend/tests/test_access_layer.py @@ -92,6 +92,7 @@ def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch): header_raw=None, memberships=[{"id": 10}], stored_active_club_id=99, + email_verified=True, ) assert ctx.effective_club_id == 99 @@ -110,6 +111,7 @@ def test_resolve_platform_admin_header_overrides_stored(monkeypatch): header_raw="5", memberships=[{"id": 10}], stored_active_club_id=99, + email_verified=True, ) assert ctx.effective_club_id == 5 @@ -124,6 +126,7 @@ def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch): header_raw=None, memberships=[{"id": 1}], stored_active_club_id=123, + email_verified=True, ) assert ctx.effective_club_id is None @@ -142,6 +145,7 @@ def test_resolve_trainer_club_ids_excludes_inactive_memberships(): ], stored_active_club_id=None, invalid_header_policy="ignore", + email_verified=True, ) assert ctx.club_ids == frozenset({20}) assert ctx.effective_club_id == 20 @@ -157,6 +161,7 @@ def test_resolve_all_memberships_inactive_no_effective_club(): memberships=[{"id": 10, "membership_status": "inactive"}], stored_active_club_id=10, invalid_header_policy="ignore", + email_verified=True, ) assert ctx.club_ids == frozenset() assert ctx.effective_club_id is None diff --git a/backend/tests/test_account_capabilities.py b/backend/tests/test_account_capabilities.py new file mode 100644 index 0000000..6e3d92b --- /dev/null +++ b/backend/tests/test_account_capabilities.py @@ -0,0 +1,86 @@ +"""Unit-Tests für Account-Lifecycle und Capability-Helfer (ohne DB).""" +import pytest +from fastapi import HTTPException + +from account_lifecycle import ( + account_state_satisfies, + assert_min_account_state, + resolve_account_state, +) +from capabilities import club_roles_in_club +from tenant_context import TenantContext + + +def test_resolve_account_state_platform_admin(): + assert ( + resolve_account_state(email_verified=False, global_role="superadmin", has_active_membership=False) + == "platform_admin" + ) + + +def test_resolve_account_state_unverified(): + assert ( + resolve_account_state(email_verified=False, global_role="trainer", has_active_membership=True) + == "unverified" + ) + + +def test_resolve_account_state_pending_club(): + assert ( + resolve_account_state(email_verified=True, global_role="user", has_active_membership=False) + == "verified_pending_club" + ) + + +def test_resolve_account_state_active_member(): + assert ( + resolve_account_state(email_verified=True, global_role="trainer", has_active_membership=True) + == "active_member" + ) + + +def test_account_state_satisfies(): + assert account_state_satisfies("active_member", "active_member") + assert account_state_satisfies("active_member", "verified_pending_club") + assert not account_state_satisfies("verified_pending_club", "active_member") + assert account_state_satisfies("platform_admin", "active_member") + + +def test_assert_min_account_state_blocks(monkeypatch): + monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "1") + tenant = TenantContext( + profile_id=1, + global_role="user", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + account_state="verified_pending_club", + ) + with pytest.raises(HTTPException) as exc: + assert_min_account_state(tenant, "active_member") + assert exc.value.status_code == 403 + + +def test_assert_min_account_state_off(monkeypatch): + monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "0") + tenant = TenantContext( + profile_id=1, + global_role="user", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + account_state="verified_pending_club", + ) + assert_min_account_state(tenant, "active_member") + + +def test_club_roles_in_club(): + tenant = TenantContext( + profile_id=1, + global_role="trainer", + effective_club_id=5, + club_ids=frozenset({5}), + memberships=[{"id": 5, "roles": ["trainer", "club_admin"]}], + ) + assert club_roles_in_club(tenant, 5) == ["trainer", "club_admin"] + assert club_roles_in_club(tenant, 99) == [] diff --git a/backend/tests/test_entitlements.py b/backend/tests/test_entitlements.py new file mode 100644 index 0000000..cc50509 --- /dev/null +++ b/backend/tests/test_entitlements.py @@ -0,0 +1,75 @@ +"""Tests für GET /me/entitlements Zusammenstellung.""" +from datetime import datetime, timezone + +from entitlements import _serialize_reset_at, build_me_entitlements +from tenant_context import TenantContext + + +def test_serialize_reset_at(): + dt = datetime(2026, 7, 1, tzinfo=timezone.utc) + assert _serialize_reset_at(dt) == "2026-07-01T00:00:00+00:00" + assert _serialize_reset_at(None) is None + + +def test_build_me_entitlements_no_club(monkeypatch): + monkeypatch.setattr( + "entitlements.resolve_capabilities_map", + lambda cur, tenant, club_id=None: {"exercises.read": False}, + ) + + tenant = TenantContext( + profile_id=1, + global_role="user", + effective_club_id=None, + club_ids=frozenset(), + memberships=[], + account_state="verified_pending_club", + ) + out = build_me_entitlements(object(), tenant) + assert out["account_state"] == "verified_pending_club" + assert out["club_id"] is None + assert out["features"] == {} + assert out["capabilities"]["exercises.read"] is False + + +def test_build_me_entitlements_with_club(monkeypatch): + monkeypatch.setattr( + "entitlements.resolve_capabilities_map", + lambda cur, tenant, club_id=None: { + "exercises.read": True, + "exercises.ai.suggest": True, + }, + ) + monkeypatch.setattr( + "entitlements.club_features_map", + lambda cur, club_id, conn=None: { + "plan_id": "free", + "club_id": club_id, + "features": { + "ai_calls": { + "allowed": False, + "used": 0, + "limit": 0, + "remaining": 0, + "reason": "feature_disabled", + "reset_at": None, + } + }, + }, + ) + monkeypatch.setattr("entitlements._club_exists", lambda cur, cid: True) + + tenant = TenantContext( + profile_id=3, + global_role="trainer", + effective_club_id=1, + club_ids=frozenset({1}), + memberships=[{"id": 1, "roles": ["trainer"]}], + account_state="active_member", + ) + out = build_me_entitlements(object(), tenant, club_id=1) + assert out["club_id"] == 1 + assert out["plan_id"] == "free" + assert out["club_roles"] == ["trainer"] + assert out["features"]["ai_calls"]["limit"] == 0 + assert out["capabilities"]["exercises.ai.suggest"] is True diff --git a/backend/version.py b/backend/version.py index bab4dec..f8d695f 100644 --- a/backend/version.py +++ b/backend/version.py @@ -2,18 +2,21 @@ APP_VERSION = "0.8.190" BUILD_DATE = "2026-05-23" -DB_SCHEMA_VERSION = "20260606078" +DB_SCHEMA_VERSION = "20260606079" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm - "profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055 - "tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert + "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles + "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext + "capabilities": "1.0.1", # resolve_capabilities_map für /me/entitlements + "account_lifecycle": "1.0.0", # resolve_account_state + assert_min_account_state (ACCOUNT_GATE_ENFORCE) "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) "admin_users": "1.0.0", # GET /api/admin/users - "club_features": "1.1.0", # M2: probe_club_feature_access + JSON-Log (Phase 2, non-blocking) + "club_features": "1.2.0", # M4: club_features_map für /me/entitlements + "entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen) "media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ba38f7f..7d19542 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import { Outlet, } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' +import { EntitlementsProvider } from './context/EntitlementsContext' import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext' import { ToastProvider } from './context/ToastContext' import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' @@ -345,11 +346,13 @@ const appRouter = createBrowserRouter([ function App() { return ( - - }> - - - + + + }> + + + + ) } diff --git a/frontend/src/components/FeatureUsageBadge.jsx b/frontend/src/components/FeatureUsageBadge.jsx new file mode 100644 index 0000000..9bc7a09 --- /dev/null +++ b/frontend/src/components/FeatureUsageBadge.jsx @@ -0,0 +1,40 @@ +import { useEntitlements } from '../context/EntitlementsContext' + +/** + * Zeigt Vereins-Kontingent für ein Feature (M4 UsageBadge). + * Unbegrenzt (limit null) → nichts rendern. + */ +export default function FeatureUsageBadge({ featureId = 'ai_calls', label = 'KI-Kontingent' }) { + const { entitlements, loading, getFeature } = useEntitlements() + const feat = getFeature(featureId) + + if (loading && !feat) { + return ( + + {label}: … + + ) + } + + if (!feat) return null + + const { used = 0, limit, remaining, allowed } = feat + if (limit == null) return null + + const tone = !allowed || remaining === 0 ? 'var(--danger)' : 'var(--text2)' + + return ( + + {label}: {used}/{limit} + {remaining != null ? ` (${remaining} übrig)` : ''} + + ) +} diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 528f280..3733bd2 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -19,6 +19,7 @@ import { stripHtmlToText } from '../../utils/htmlUtils' import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor' import ExerciseSkillsEditor from './ExerciseSkillsEditor' import { useAuth } from '../../context/AuthContext' +import FeatureUsageBadge from '../FeatureUsageBadge' import { useToast } from '../../context/ToastContext' import { activeClubMemberships, @@ -1653,15 +1654,18 @@ function ExerciseFormPageRoot() { - +
+ + +
{ + if (!isAuthenticated) { + setEntitlements(null) + setError(null) + return null + } + setLoading(true) + setError(null) + try { + const data = await getMeEntitlements(clubId) + setEntitlements(data) + return data + } catch (e) { + setEntitlements(null) + setError(e?.message || String(e)) + return null + } finally { + setLoading(false) + } + }, [isAuthenticated, clubId]) + + useEffect(() => { + if (authLoading) return + refreshEntitlements() + }, [authLoading, refreshEntitlements]) + + const value = useMemo( + () => ({ + entitlements, + loading, + error, + refreshEntitlements, + hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]), + getFeature: (featureId) => entitlements?.features?.[featureId] ?? null, + }), + [entitlements, loading, error, refreshEntitlements], + ) + + return ( + {children} + ) +} + +export function useEntitlements() { + const ctx = useContext(EntitlementsContext) + if (!ctx) { + throw new Error('useEntitlements must be used within EntitlementsProvider') + } + return ctx +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 57ed2bb..7294ba9 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -40,6 +40,15 @@ export async function getCurrentProfile() { return request('/api/profiles/me') } +/** Effektive Capabilities + Feature-Kontingente (M4). */ +export async function getMeEntitlements(clubId = null) { + const q = + clubId != null && clubId !== '' + ? `?club_id=${encodeURIComponent(String(clubId))}` + : '' + return request(`/api/me/entitlements${q}`) +} + /** Liste aller Profile – nur für Plattform-Admins (Vereinsanlage). */ export async function listProfiles() { return request('/api/profiles') @@ -850,6 +859,7 @@ export const api = { register, logout, getCurrentProfile, + getMeEntitlements, listProfiles, listAdminUsers, getAdminUserContentMeta, -- 2.43.0 From a2f60d3f46967803443d86d58921361ad02cf954 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 7 Jun 2026 05:57:13 +0200 Subject: [PATCH 04/30] Update Capability Catalog and Club Membership Documentation - Revised the status in the Capability Catalog to reflect partial implementation (M3). - Added a new reference to `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` in both the Capability Catalog and Club Membership documentation. - Enhanced the Club Membership documentation with details on product decisions and onboarding phases. - Implemented middleware in the backend to restrict access for unverified users and those pending club membership. - Updated versioning in `version.py` to reflect changes in account lifecycle management. --- .../docs/technical/CAPABILITY_CATALOG.v1.md | 6 +- .../CLUB_MEMBERSHIP_AND_FEATURES.v1.md | 17 +- .../MEMBERSHIP_RBAC_DECISIONS_2026-06.md | 210 ++++++++++++++++++ backend/account_onboarding_gate.py | 165 ++++++++++++++ backend/main.py | 28 +++ backend/tests/test_account_onboarding_gate.py | 58 +++++ backend/version.py | 2 +- frontend/src/App.jsx | 29 ++- frontend/src/components/DesktopSidebar.jsx | 6 +- frontend/src/config/appNav.js | 13 +- frontend/src/pages/OnboardingPage.jsx | 208 +++++++++++++++++ frontend/src/utils/accountState.js | 38 ++++ 12 files changed, 765 insertions(+), 15 deletions(-) create mode 100644 .claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md create mode 100644 backend/account_onboarding_gate.py create mode 100644 backend/tests/test_account_onboarding_gate.py create mode 100644 frontend/src/pages/OnboardingPage.jsx create mode 100644 frontend/src/utils/accountState.js diff --git a/.claude/docs/technical/CAPABILITY_CATALOG.v1.md b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md index fb4f1bf..0e403d5 100644 --- a/.claude/docs/technical/CAPABILITY_CATALOG.v1.md +++ b/.claude/docs/technical/CAPABILITY_CATALOG.v1.md @@ -1,8 +1,8 @@ # Capability-Katalog Shinkan v1 -**Status:** Konzept (verbindliche Zieldefinition vor Implementierung) +**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` +**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) --- @@ -50,7 +50,7 @@ Objektbezogene Feinheiten (nur Ersteller, nur Vereinsadmin des Objekt-Vereins) b |-----------------|-----------|------------------------| | `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`, `account.settings` | +| `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 | diff --git a/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md index ffced68..813cc53 100644 --- a/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md +++ b/.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md @@ -1,8 +1,8 @@ # Vereins-Membership & Feature-System Shinkan v1 -**Status:** Konzept (Schema- und Enforcement-Zielbild vor Implementierung) +**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` +**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`** --- @@ -430,6 +430,17 @@ Unabhängig vom Membership-System — **Pflicht** wegen Prod-Vorfälle (`access_ | 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 @@ -440,7 +451,7 @@ Vor M6 festlegen: 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? (Capability-Doc) +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) --- 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..df6d355 --- /dev/null +++ b/.claude/docs/technical/MEMBERSHIP_RBAC_DECISIONS_2026-06.md @@ -0,0 +1,210 @@ +# 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) → Vereins-Kontingent. + +**Fairness-Modell (offen, Tendenz):** harte Sub-Budgets (Modell A) — Trainer darf sein Budget nicht überschreiten, auch wenn Verein noch Rest hat. + +--- + +### 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:** `20260606079` (`backend/version.py`) +**Deploy-Referenz:** Dev mit M2-Logging verifiziert (`club-feature-usage.log`). + +### 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 | ✅ | +| `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) | ❌ | — | +| Custom Roles / Co-Trainer | ❌ | bewusst v2 | +| Legacy-Helfer entfernt | ❌ | bewusst parallel | + +### Bewusst zurückgestellt (Roadmap) + +| ID | Inhalt | +|----|--------| +| M0 | CI-Isolation / Test-DB | +| M5 | Hard-Block Kontingente | +| M6 | Superadmin Admin-UI (Pläne, Capability-Matrix) | +| M7 | Vereinsgründung beantragen | +| M8 | Stripe | + +### Hinweis: M4 im Repo (über M3 hinaus) + +Falls bereits deployed: `GET /api/me/entitlements`, `EntitlementsContext`, `FeatureUsageBadge` — gehört zur **Anzeige-Phase 3**, nicht zum M3-Kern. Siehe `entitlements` Modul v1.0.0. + +--- + +## 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 Superadmin-UI** | Pläne + Capability-Matrix ohne SQL | +| **E** | Systemrolle `co_trainer` + Entitlements im Frontend | Entscheidung 1.2 risikoarm | +| **F** | Member-Budgets (v2) | Entscheidung 1.4 | + +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 | + +**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. diff --git a/backend/account_onboarding_gate.py b/backend/account_onboarding_gate.py new file mode 100644 index 0000000..bbe9e25 --- /dev/null +++ b/backend/account_onboarding_gate.py @@ -0,0 +1,165 @@ +""" +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 is_platform_admin, 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", +) + +_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$") + + +def api_onboarding_gate_enabled() -> bool: + return os.getenv("ACCOUNT_GATE_API_ENFORCE", "1").strip() == "1" + + +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(): + 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/main.py b/backend/main.py index 81884bf..331c067 100644 --- a/backend/main.py +++ b/backend/main.py @@ -87,6 +87,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).""" diff --git a/backend/tests/test_account_onboarding_gate.py b/backend/tests/test_account_onboarding_gate.py new file mode 100644 index 0000000..e5c2aa6 --- /dev/null +++ b/backend/tests/test_account_onboarding_gate.py @@ -0,0 +1,58 @@ +"""Tests für account_onboarding_gate.""" +import pytest + +from account_onboarding_gate import ( + check_api_onboarding_gate, + is_public_api_path, + normalize_api_path, +) + + +def test_public_directory_is_public(): + assert is_public_api_path("/api/clubs/public-directory") + + +def test_exercises_blocked_for_pending(): + allowed, reason = check_api_onboarding_gate( + path="/api/exercises", + method="GET", + profile_id=1, + account_state="verified_pending_club", + ) + assert not allowed + assert reason == "account_state_verified_pending_club" + + +def test_join_request_allowed_for_pending(): + allowed, _ = check_api_onboarding_gate( + path="/api/me/club-join-requests", + method="POST", + profile_id=1, + account_state="verified_pending_club", + ) + assert allowed + + +def test_active_member_domain_ok(): + allowed, reason = check_api_onboarding_gate( + path="/api/exercises", + method="GET", + profile_id=1, + account_state="active_member", + ) + assert allowed + assert reason is None + + +def test_profile_self_update_allowed_unverified(): + allowed, _ = check_api_onboarding_gate( + path="/api/profiles/42", + method="PUT", + profile_id=42, + account_state="unverified", + ) + assert allowed + + +def test_normalize_trailing_slash(): + assert normalize_api_path("/api/exercises/") == "/api/exercises" diff --git a/backend/version.py b/backend/version.py index f8d695f..e9d351d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -10,7 +10,7 @@ MODULE_VERSIONS = { "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext "capabilities": "1.0.1", # resolve_capabilities_map für /me/entitlements - "account_lifecycle": "1.0.0", # resolve_account_state + assert_min_account_state (ACCOUNT_GATE_ENFORCE) + "account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7d19542..38702a0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,7 @@ import { ToastProvider } from './context/ToastContext' import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import DesktopSidebar from './components/DesktopSidebar' import { getMainNavItems } from './config/appNav' +import { isOnboardingAllowedPath, isOnboardingRestricted } from './utils/accountState' import AdminHomeRedirect from './components/AdminHomeRedirect' import PlatformAdminRoute from './components/PlatformAdminRoute' import ActiveClubSwitcher from './components/ActiveClubSwitcher' @@ -21,6 +22,7 @@ import InactiveMembershipBanner from './components/InactiveMembershipBanner' import './app.css' const LoginPage = lazy(() => import('./pages/LoginPage')) +const OnboardingPage = lazy(() => import('./pages/OnboardingPage')) const VerifyPage = lazy(() => import('./pages/VerifyPage')) const Dashboard = lazy(() => import('./pages/Dashboard')) const AccountSettingsPage = lazy(() => import('./pages/AccountSettingsPage')) @@ -83,9 +85,12 @@ function AppRouteFallback() { } // Bottom Navigation (Mobile) -function Nav({ showAdminNav }) { +function Nav({ showAdminNav, onboardingOnly }) { const { canAccessOrgInbox, inboxCount } = useOrgInbox() - const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox }) + const items = getMainNavItems(showAdminNav, { + showInbox: canAccessOrgInbox, + onboardingOnly, + }) const loc = useLocation() const navItemActive = (pathname, item, routerIsActive) => { @@ -147,26 +152,37 @@ function ProtectedLayout() { return } - const showAdminNav = computeShowAdminNav(user) + const location = useLocation() + const onboardingOnly = isOnboardingRestricted(user) + if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) { + return + } + + const showAdminNav = computeShowAdminNav(user) && !onboardingOnly return ( - +
🥋 Shinkan
- + {!onboardingOnly ? : null}
-
@@ -220,6 +236,7 @@ const appRouter = createBrowserRouter([ element: , children: [ { index: true, element: }, + { path: 'onboarding', element: }, { path: 'profile', element: }, { path: 'settings', element: }, { path: 'settings/system', element: }, diff --git a/frontend/src/components/DesktopSidebar.jsx b/frontend/src/components/DesktopSidebar.jsx index f5f582a..b8bcdf9 100644 --- a/frontend/src/components/DesktopSidebar.jsx +++ b/frontend/src/components/DesktopSidebar.jsx @@ -14,12 +14,16 @@ function sidebarLinkActive(pathname, item, routerIsActive) { */ export default function DesktopSidebar({ showAdminNav, + onboardingOnly = false, user, onLogout }) { const loc = useLocation() const { canAccessOrgInbox, inboxCount } = useOrgInbox() - const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox }) + const items = getMainNavItems(showAdminNav, { + showInbox: canAccessOrgInbox, + onboardingOnly, + }) const tier = user?.tier || '' return ( diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js index 4a10fee..0c4b13d 100644 --- a/frontend/src/config/appNav.js +++ b/frontend/src/config/appNav.js @@ -31,8 +31,19 @@ function baseItems(opts = {}) { return items } -/** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */ +/** Nav für Onboarding (ohne Vereinsmitgliedschaft). */ +export function getOnboardingNavItems() { + return [ + { to: '/onboarding', label: 'Verein', shortLabel: 'Verein', end: true, Icon: Building2 }, + { to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.', Icon: Settings }, + ] +} + +/** @param {boolean} isAdmin @param {{ showInbox?: boolean, onboardingOnly?: boolean }} opts */ export function getMainNavItems(isAdmin, opts = {}) { + if (opts.onboardingOnly) { + return getOnboardingNavItems() + } const showInbox = !!opts.showInbox const icons = [ LayoutDashboard, diff --git a/frontend/src/pages/OnboardingPage.jsx b/frontend/src/pages/OnboardingPage.jsx new file mode 100644 index 0000000..7503209 --- /dev/null +++ b/frontend/src/pages/OnboardingPage.jsx @@ -0,0 +1,208 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../utils/api' +import { useAuth } from '../context/AuthContext' +import EmailVerificationBanner from '../components/EmailVerificationBanner' +import { resolveAccountState } from '../utils/accountState' + +const joinStatusLabel = (s) => + ({ + pending: 'ausstehend', + accepted: 'angenommen', + rejected: 'abgelehnt', + withdrawn: 'zurückgezogen', + })[s] || s + +/** + * Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A). + */ +export default function OnboardingPage() { + const { user, checkAuth } = useAuth() + const [publicClubs, setPublicClubs] = useState([]) + const [myJoinRequests, setMyJoinRequests] = useState([]) + const [joinClubId, setJoinClubId] = useState('') + const [joinMessage, setJoinMessage] = useState('') + const [joinBusy, setJoinBusy] = useState(false) + const [error, setError] = useState('') + const [ok, setOk] = useState('') + + const accountState = resolveAccountState(user) + const emailOk = accountState !== 'unverified' + + const refreshJoinRequests = () => { + if (!emailOk) return + api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {}) + } + + useEffect(() => { + api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {}) + refreshJoinRequests() + }, [user?.id, emailOk]) + + const memberClubIds = new Set((user?.clubs || []).map((c) => c.id)) + const pendingClubIds = new Set( + myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id) + ) + const joinClubChoices = publicClubs.filter( + (c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id) + ) + + const handleJoin = async (e) => { + e.preventDefault() + setError('') + setOk('') + if (!joinClubId) { + setError('Bitte einen Verein auswählen.') + return + } + setJoinBusy(true) + try { + await api.createClubJoinRequest({ + club_id: parseInt(joinClubId, 10), + message: (joinMessage || '').trim() || undefined, + }) + setJoinMessage('') + setJoinClubId('') + refreshJoinRequests() + await checkAuth() + setOk('Antrag gesendet. Der Vereinsadmin kann ihn unter Vereinsverwaltung annehmen.') + } catch (err) { + setError(err.message || 'Antrag fehlgeschlagen.') + } finally { + setJoinBusy(false) + } + } + + return ( +
+

Willkommen bei Shinkan

+

+ Shinkan ist die Trainingsplanungs-Plattform für Vereine. Um Übungen, Planung und Medien zu nutzen, + brauchst du eine Mitgliedschaft in einem Verein — oder du beantragst die Gründung eines neuen Vereins. +

+ + + + {!emailOk ? ( +
+

+ Bitte bestätige zuerst deine E-Mail-Adresse. Danach kannst du einen Beitrittsantrag stellen. +

+
+ ) : ( + <> + {ok ? ( +

+ {ok} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + +
+

Bestehendem Verein beitreten

+

+ Wähle einen Verein und sende einen Beitrittsantrag. Nach Freigabe durch den Vereinsadmin + stehen dir alle Funktionen zur Verfügung. +

+ + {myJoinRequests.length > 0 ? ( +
+ Meine Anträge +
    + {myJoinRequests.map((r) => ( +
  • + {r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)} + {r.status === 'pending' ? ( + <> + {' '} + + + ) : null} +
  • + ))} +
+
+ ) : null} + +
+ + + +