Update Access Layer and Governance Documentation
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m12s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m12s
- 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.
This commit is contained in:
parent
bd5a409fa7
commit
c294c27de8
|
|
@ -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.
|
||||
|
|
|
|||
328
.claude/docs/technical/CAPABILITY_CATALOG.v1.md
Normal file
328
.claude/docs/technical/CAPABILITY_CATALOG.v1.md
Normal file
|
|
@ -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.
|
||||
464
.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Normal file
464
.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
336
backend/club_features.py
Normal file
336
backend/club_features.py
Normal file
|
|
@ -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}
|
||||
264
backend/migrations/078_club_features_and_plans.sql
Normal file
264
backend/migrations/078_club_features_and_plans.sql
Normal file
|
|
@ -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
|
||||
);
|
||||
27
backend/tests/test_club_features.py
Normal file
27
backend/tests/test_club_features.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user