diff --git a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md index 591a9cf..73e0c34 100644 --- a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md +++ b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md @@ -6,6 +6,7 @@ Ausführliche fachliche Inhalte: |----------|--------| | [shinkan_anforderungsdokument_entwurf.md](./shinkan_anforderungsdokument_entwurf.md) | Gesamtentwurf Anforderungen | | [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (Abschnitt 11.2) | +| [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md) | Zielarchitektur Mandanten/Rollen/Membership & Umsetzungsplan | **Lieferstand & Umsetzung (Stand Code):** siehe [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md) und [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md). diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md new file mode 100644 index 0000000..244ef6e --- /dev/null +++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md @@ -0,0 +1,213 @@ +# Multi-Tenancy, Vereins-Membership und Rollenmodell – Zielarchitektur & Umsetzungsplan + +**Status:** verbindliche Zielrichtung (Architekturpapier) +**Stand:** 2026-05-05 +**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008) + +--- + +## 1. Zweck + +Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Mandant), **rollenbasierte Zugriffskontrolle auf Vereinsebene** und ein **Membership-/Limitsystem** zusammen – und definiert einen **phasenweisen Umsetzungsplan**, der mit dem bestehenden Governance-Kern (`visibility`, `club_id`, `created_by`) konsistent bleibt. + +--- + +## 2. Abgleich mit vorhandener Dokumentation + +| Quelle | Inhalt relevant für Tenancy/Rollen | Konsistenz mit Zielbild | +|--------|-----------------------------------|-------------------------| +| `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** | +| §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** | +| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; **technische Durchsetzung** ist noch lückenhaft | +| §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung | +| `DOMAIN_MODEL.md` §5.5 | Freigabeebenen inkl. Sparte | Zielbild; DB/API nutzen derzeit überwiegend `private` \| `club` \| `official` | +| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004–008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt | +| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel – UI-Komplexität kontrolliert einführen | + +**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten. + +--- + +## 3. Ist-Stand im Code (Gap-Analyse) + +### 3.1 Identität und Rollen + +- `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …). +- **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**. +- Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`). + +**Konsequenz:** Mehrere Vereine mit unterschiedlichen Rollen pro User sind **nicht modelliert**; ein „Vereinsadmin“ kann nicht sauber von einem reinen Trainer unterschieden werden, sobald beides nur über `profiles.role` laufen soll. + +### 3.2 Organisation & APIs + +- `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`). +- `GET /api/clubs` listiert **alle** Vereine für jeden eingeloggten Nutzer. +- `POST /api/clubs` erlaubt Anlage für `trainer` und `user` – **nicht** nur Systemadmin. +- Sparten/Gruppen: Schreibzugriff über globale `admin`/`superadmin`, nicht über **Vereinsadmin** im Kontext „sein Verein“. + +**Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt. + +### 3.3 Trainingsplanung + +- Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`). +- `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ – **kein** generelles Mitgliedschaftsmodell. + +**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht. + +### 3.4 Governance / Sichtbarkeit (kritisch) + +- Übungen (`list_exercises`): Bedingung sinngemäß „official OR club OR created_by = ich“ – **`club` gilt für alle Mandanten**, ohne Prüfung `exercise.club_id` ∈ Vereine des Nutzers. +- Detailzugriff `private`: nur Owner – **ok**. +- Rahmenprogramme (`training_framework_programs`): Lesen fremder Rahmen über `visibility=club` ist in `_framework_access` **nicht** gelöst (faktisch stark creator-basiert für Nicht-Admins). + +**Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen. + +### 3.5 Frontend + +- `AuthContext`: nur globales Profil, **kein** `activeClubId`, keine Mitgliedschaften. + +### 3.6 Membership (kommerziell/limits) + +- Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**. +- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema. + +--- + +## 4. Zielarchitektur + +### 4.1 Begriffe + +| Begriff | Definition | +|---------|------------| +| **Mandant** | Immer ein **Verein** (`clubs.id`). | +| **Systemadmin** | Global (`profiles.role` oder dediziertes Flag); darf Plattform-weite Objekte und **Vereinslifecycle** (Anlegen, Zuweisen Hauptverwalter). | +| **Vereinskontext** | Pro Session gewählter aktiver Verein (`active_club_id`), wenn der User Mitglied ist. | +| **Vereinsmitgliedschaft** | Zeile in einer Junction: User ↔ Verein ↔ **eine oder mehrere Rollen**. | +| **Effektive Berechtigung** | Funktion aus: globale Rolle, Mitgliedschaft im aktiven Verein, optional Sparte/Gruppe, Sichtbarkeit des Objekts. | + +### 4.2 Rollenmodell (Schichten) + +**Schicht A – Plattform (global, kleine Menge)** + +- `system_admin` / `superadmin` (bestehende Semantik konsolidieren und benennen). + +**Schicht B – Verein (pro Mitgliedschaft, mehrere Rollen möglich)** + +- `club_admin` – Hauptverwalter:in (ein Verein **genau eine** „primäre“ Admin-Zuweisung empfohlen, siehe 4.4). +- `division_lead` – Spartenverantwortliche:r (Scope: `division_id` optional an Mitgliedschaft gebunden). +- `trainer` – Trainer/Übungsleitung (Abgrenzung zu Co-Trainer siehe Gruppe). +- `content_editor` – Redakteur:in / Inhaltsverantwortliche:r (fachlich wie Anforderungsdoc). + +**Schicht C – Abgeleitet aus Trainingsgruppe (bereits teilweise vorhanden)** + +- Haupttrainer / Co-Trainer über `training_groups.trainer_id` und `co_trainer_ids` (und ggf. `lead_trainer_profile_id` auf Einheit). + +**Mapping Alt → Neu:** Bestehendes `profiles.role` kann Übergangsweise als „Default-Rolle für Pilotverein“ dienen, soll aber mittelfristig **nicht** die einzige Quelle für Vereinsrechte sein. + +### 4.3 Mitgliedschaft und aktiver Verein + +- Neue Kernstruktur (konzeptionell): + **`club_members`** (`profile_id`, `club_id`, `status`, `created_at`, …) + **`club_member_roles`** (`club_member_id`, `role_code`, optional `division_id` für spartenbezogene Rollen). + +- **Aktiver Verein:** + - Persistenz: Nutzereinstellung (`profiles.default_club_id` oder eigene Tabelle `profile_preferences`). + - Pro Request: Header **`X-Active-Club-Id`** oder Query (einheitlich dokumentieren); Server validiert Mitgliedschaft. + +- **Tenant-Switch UI:** Bei Mitgliedschaft in >1 Verein Auswahl im Frontend; alle Listen/Aktionen verwenden aktiven Kontext. + +### 4.4 Vereins-Lebenszyklus + +- Neuer Verein: **nur Systemadmin**; Pflichtfelder: Name, initialer `club_admin` (bestehendes Profil zuweisen oder Einladungsflow später). +- Vereinsadmin verwaltet in „Mein Verein“: Sparten, Gruppen, Trainerzuordnungen, Einladungen (später), interne Sichtbarkeit – **ohne** andere Vereine zu sehen. + +### 4.5 Daten- und Funktionssicht + +| Datenklasse | Leseregel (Ziel) | +|-------------|------------------| +| Global offiziell | Alle authentifizierten (ggf. später thematisch eingeschränkt). | +| Verein (`visibility=club`, `club_id` gesetzt) | Nur Profile mit Mitgliedschaft in **diesem** `club_id`; optional zusätzlich Sparte, wenn `division_id` am Objekt gepflegt wird. | +| Privat | Nur `created_by` (und explizite Shares später). | +| Geplante Einheiten | Wie heute über Gruppe + Trainer/Co; zusätzlich Vereinskontext zur Navigation/Audit. | + +**Einheitlicher Governance-Kern** bleibt wie in CURR-005; Ergänzung: **`division_id`** auf Bibliotheksobjekten, wenn „Sparte“ technisch durchgesetzt werden soll (DOMAIN_MODEL / §17). + +### 4.6 Membership-System (Vereinsabo / Limits) – Konzept + +Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tier-Infrastuktur, aber an **`club_id`** gebunden. + +**Empfohlene Bausteine:** + +1. **`club_plans`** – Produktdefinition (Name, Features, implizite Limits). +2. **`club_subscriptions`** – (`club_id`, `plan_id`, Status, Laufzeit). +3. **`club_usage_counters`** oder Ableitung aus DB – z. B. aktive Nutzer, aktive Trainingsgruppen (periodisch oder on-write). +4. **Enforcement-Schicht** – zentrale Funktion `assert_club_limit(club_id, metric)` vor `/groups`-POST, Einladungen, etc. + +**Offene Produktentscheidungen** (vor Implementierung festlegen): + +- Zählen „Nutzer“ alle Mitglieder oder nur aktive Trainer? +- Soft-Limits vs. Hard-Stops; Übergang für Pilotverein. + +--- + +## 5. Umsetzungsplan (Phasen) + +### Phase 0 – Foundations (kurz, risikoarm) + +- Begriffe und Enums in einem Ort dokumentieren (dieses Dokument + Eintrag in `DATABASE_SCHEMA.md` nach Migration). +- Audit-Liste aller Router mit `club_id` / `visibility` / Listen-Endpunkten. + +### Phase 1 – Datenmodell Mitgliedschaft & Hauptverwalter + +- Migration: `club_members`, `club_member_roles`; optional `clubs.primary_admin_profile_id` (oder Primär-Flag auf Mitgliedschaft). +- Backfill: bestehende Trainer aus `training_groups` → minimale Mitgliedschaft `trainer` im jeweiligen Verein (Skript/Migration mit dokumentierter Annahme CURR-008). +- **Breaking/API:** keine – nur erweiterte Datenbasis. + +### Phase 2 – Aktiver Vereinskontext & API-Kontrakte + +- Backend: Validierung `X-Active-Club-Id` gegen Mitgliedschaft; Hilfsfunktion `get_effective_club_context(session, header)`. +- `GET /api/clubs` für Nicht-Systemadmins: **nur Vereine mit Mitgliedschaft**. +- `POST /api/clubs`: nur Systemadmin; Vergabe `club_admin`-Mitgliedschaft. +- Frontend: Club-Switcher + Persistenz. + +### Phase 3 – Effektive Berechtigungen (RBAC) + +- Zentrale Modulfunktion z. B. `authorization/club_permissions.py`: + `can(club_id, profile_id, permission, division_id=None)`. +- Router schrittweise umbinden: Sparten/Gruppen CRUD nach Rolle `club_admin` im Kontext; Systemadmin unverändert Vollzugriff. + +### Phase 4 – Sichtbarkeit & Leaks schließen + +- **Übungen:** `club`-Sichtbarkeit nur bei Übereinstimmung `exercise.club_id` mit Mitgliedschaft (und später `division`). +- Gleiches Muster für `training_plan_templates`, `training_framework_programs`, Progressionsgraphen, ggf. Medien. +- Tests: zwei Vereine, zwei Nutzer, keine Kreuzzugriffe. + +### Phase 5 – Mitgliedschaft / Limits + +- Tabellen `club_plans`, `club_subscriptions`; Integration mit Enforcement vor relevanten Writes. +- UI „Mein Verein“: Kennzahlenteaser oder Hinweise bei Limit (minimal). + +### Phase 6 – Verfeinerung + +- Einladungsflow (E-Mail), Mehrfachrollen-UI, Audit-Log für Admin-Aktionen. +- Optionale thematische Sperren (Karate vs. Gewaltschutz) als eigene Policy-Schicht. + +--- + +## 6. Abhängigkeiten und Risiken + +- **Übergang:** Pilot mit einem Verein nutzt weiterhin einfache Defaults; Multi-Verein erfordert Pflicht **aktiver Kontext**. +- **Performance:** Mitgliedschaft und Rolle sollten **einmal pro Request** geladen und gecacht werden (Request-Scope). +- **Konsistenz mit Mitai:** Nutzer-Tiers können parallel bleiben; **vereinsbezogene** Limits sind die neue Quelle für Shinkan-spezifische Kaufmotive. + +--- + +## 7. Nächste konkrete Artefakte + +1. DDL-Migrationsentwurf für Phase 1 (Review). +2. Aktualisierung `DATABASE_SCHEMA.md` nach Merge der Migration. +3. Sicherheits-Review der `list_*`-Endpunkte mit `club`-Visibility. + +--- + +**Letzte Aktualisierung:** 2026-05-05 diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py new file mode 100644 index 0000000..21f954b --- /dev/null +++ b/backend/club_tenancy.py @@ -0,0 +1,127 @@ +""" +Vereins-Mandanten: Mitgliedschaften, aktiver Vereinskontext, einfache Berechtigungen. + +Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md +""" +from typing import Any, Dict, List, Optional, Set + +from fastapi import HTTPException + +def is_platform_admin(role: Optional[str]) -> bool: + return (role or "").lower() in ("admin", "superadmin") + + +def club_ids_for_profile(cur, profile_id: int) -> Set[int]: + cur.execute( + """ + SELECT club_id FROM club_members + WHERE profile_id = %s AND status = 'active' + """, + (profile_id,), + ) + return {r["club_id"] for r in cur.fetchall()} + + +def assert_club_member(cur, profile_id: int, club_id: int) -> None: + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND status = 'active' + LIMIT 1 + """, + (profile_id, club_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein") + + +def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool: + if not role_codes: + return False + cur.execute( + """ + SELECT 1 + FROM club_members cm + INNER JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active' + AND r.role_code IN ({ph}) + LIMIT 1 + """.format(ph=",".join(["%s"] * len(role_codes))), + (profile_id, club_id, *role_codes), + ) + return cur.fetchone() is not None + + +def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool: + """Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin.""" + if is_platform_admin(global_role): + return True + return has_club_role(cur, profile_id, club_id, "club_admin") + + +def can_plan_in_club(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool: + """Trainingsgruppen anlegen / planen: Admin-Rollen im Verein oder Plattform.""" + if is_platform_admin(global_role): + return True + return has_club_role( + cur, profile_id, club_id, "club_admin", "trainer", "content_editor", "division_lead" + ) + + +def memberships_with_roles(cur, profile_id: int) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT c.id, c.name, c.abbreviation, c.status, + COALESCE( + ARRAY_AGG(DISTINCT r.role_code) FILTER (WHERE r.role_code IS NOT NULL), + ARRAY[]::varchar[] + ) AS roles + FROM club_members cm + INNER JOIN clubs c ON c.id = cm.club_id + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.profile_id = %s AND cm.status = 'active' + GROUP BY c.id, c.name, c.abbreviation, c.status + ORDER BY c.name + """, + (profile_id,), + ) + out: List[Dict[str, Any]] = [] + for row in cur.fetchall(): + d = dict(row) + roles = d.get("roles") or [] + if hasattr(roles, "tolist"): + roles = roles.tolist() + d["roles"] = list(roles) + out.append(d) + return out + + +def exercise_visible_to_profile( + cur, + profile_id: int, + visibility: str, + exercise_club_id: Optional[int], + created_by: Optional[int], + global_role: Optional[str], +) -> bool: + if is_platform_admin(global_role): + return True + if visibility == "official": + return True + if created_by is not None and created_by == profile_id: + return True + if visibility == "private": + return False + if visibility == "club": + if exercise_club_id is None: + return False + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND status = 'active' + LIMIT 1 + """, + (profile_id, exercise_club_id), + ) + return cur.fetchone() is not None + return False diff --git a/backend/main.py b/backend/main.py index ad11a2e..d7a0ab9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -99,6 +99,8 @@ def health_ready(): "skill_categories", "maturity_models", "sessions", + "club_members", + "club_member_roles", ) tables: dict = {} err: Optional[str] = None diff --git a/backend/migrations/039_club_membership_rbac.sql b/backend/migrations/039_club_membership_rbac.sql new file mode 100644 index 0000000..700187e --- /dev/null +++ b/backend/migrations/039_club_membership_rbac.sql @@ -0,0 +1,114 @@ +-- Migration 039: Vereins-Mitgliedschaft, Rollen pro Verein, aktiver Vereinskontext (Multi-Tenancy Phase 1) +-- Erstellt: 2026-05-05 + +-- Mitgliedschaft Profil ↔ Verein +CREATE TABLE club_members ( + id SERIAL PRIMARY KEY, + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(profile_id, club_id) +); + +CREATE INDEX idx_club_members_profile ON club_members(profile_id); +CREATE INDEX idx_club_members_club ON club_members(club_id); +CREATE INDEX idx_club_members_status ON club_members(status); + +-- Rollen pro Mitgliedschaft (role_code: club_admin, trainer, division_lead, content_editor) +CREATE TABLE club_member_roles ( + id SERIAL PRIMARY KEY, + club_member_id INT NOT NULL REFERENCES club_members(id) ON DELETE CASCADE, + role_code VARCHAR(50) NOT NULL, + division_id INT REFERENCES divisions(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (club_member_id, role_code) +); + +CREATE INDEX idx_club_member_roles_member ON club_member_roles(club_member_id); +CREATE INDEX idx_club_member_roles_code ON club_member_roles(role_code); + +-- Hauptverwalter:in (fachliche Referenz; Rechte über club_member_roles.club_admin) +ALTER TABLE clubs ADD COLUMN IF NOT EXISTS primary_admin_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL; + +CREATE INDEX idx_clubs_primary_admin ON clubs(primary_admin_profile_id); + +-- Persistenz gewählter aktiver Verein (UI); Request-Header kann überschreiben +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS active_club_id INT REFERENCES clubs(id) ON DELETE SET NULL; + +CREATE INDEX idx_profiles_active_club ON profiles(active_club_id); + +-- ── Backfill: Trainer/Co-Trainer aus Trainingsgruppen → Mitgliedschaft + Rolle trainer +INSERT INTO club_members (profile_id, club_id, status) +SELECT DISTINCT t.trainer_id, t.club_id, 'active' +FROM training_groups t +WHERE t.trainer_id IS NOT NULL +ON CONFLICT (profile_id, club_id) DO NOTHING; + +INSERT INTO club_members (profile_id, club_id, status) +SELECT DISTINCT elem::int, t.club_id, 'active' +FROM training_groups t, +LATERAL jsonb_array_elements_text(COALESCE(t.co_trainer_ids, '[]'::jsonb)) AS elem +WHERE jsonb_array_length(COALESCE(t.co_trainer_ids, '[]'::jsonb)) > 0 +ON CONFLICT (profile_id, club_id) DO NOTHING; + +INSERT INTO club_member_roles (club_member_id, role_code) +SELECT cm.id, 'trainer' +FROM club_members cm +WHERE NOT EXISTS ( + SELECT 1 FROM club_member_roles r + WHERE r.club_member_id = cm.id AND r.role_code = 'trainer' +); + +-- Pro Verein: kleinste trainer_id einer Gruppe als primärer Admin (Pilot / CURR-008-Analog) +UPDATE clubs c +SET primary_admin_profile_id = sub.pid +FROM ( + SELECT DISTINCT ON (club_id) + club_id, + trainer_id AS pid + FROM training_groups + WHERE trainer_id IS NOT NULL + ORDER BY club_id, id ASC +) sub +WHERE c.id = sub.club_id + AND c.primary_admin_profile_id IS NULL; + +INSERT INTO club_member_roles (club_member_id, role_code) +SELECT cm.id, 'club_admin' +FROM club_members cm +INNER JOIN clubs cl ON cl.id = cm.club_id AND cl.primary_admin_profile_id = cm.profile_id +WHERE NOT EXISTS ( + SELECT 1 FROM club_member_roles r + WHERE r.club_member_id = cm.id AND r.role_code = 'club_admin' +); + +-- Globale Admins: in jedem bestehenden Verein als club_admin spiegeln (volle Plattform-Sicht) +INSERT INTO club_members (profile_id, club_id, status) +SELECT p.id, c.id, 'active' +FROM profiles p +CROSS JOIN clubs c +WHERE p.role IN ('admin', 'superadmin') +ON CONFLICT (profile_id, club_id) DO NOTHING; + +INSERT INTO club_member_roles (club_member_id, role_code) +SELECT cm.id, 'club_admin' +FROM club_members cm +INNER JOIN profiles p ON p.id = cm.profile_id AND p.role IN ('admin', 'superadmin') +WHERE NOT EXISTS ( + SELECT 1 FROM club_member_roles r + WHERE r.club_member_id = cm.id AND r.role_code = 'club_admin' +); + +-- Default aktiver Verein: einziger Verein des Nutzers +UPDATE profiles p +SET active_club_id = sub.only_club +FROM ( + SELECT profile_id, MIN(club_id) AS only_club + FROM club_members + WHERE status = 'active' + GROUP BY profile_id + HAVING COUNT(DISTINCT club_id) = 1 +) sub +WHERE p.id = sub.profile_id AND (p.active_club_id IS NULL); diff --git a/backend/models.py b/backend/models.py index e2563e8..f8c9cb9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -34,6 +34,7 @@ class ProfileCreate(BaseModel): class ProfileUpdate(BaseModel): name: Optional[str] = None email: Optional[str] = None + active_club_id: Optional[int] = None class ProfileResponse(BaseModel): id: int diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py index 9a630a0..bcfb2ed 100644 --- a/backend/routers/clubs.py +++ b/backend/routers/clubs.py @@ -3,11 +3,18 @@ Club & Organization Management Endpoints for Shinkan Jinkendo Handles CRUD operations for clubs, divisions, and training groups. """ -from typing import Optional +from typing import Any, List, Optional from fastapi import APIRouter, HTTPException, Depends, Query from db import get_db, get_cursor, r2d from auth import require_auth +from club_tenancy import ( + assert_club_member, + can_manage_club_org, + can_plan_in_club, + club_ids_for_profile, + is_platform_admin, +) router = APIRouter(prefix="/api", tags=["clubs"]) @@ -16,24 +23,33 @@ router = APIRouter(prefix="/api", tags=["clubs"]) @router.get("/clubs") def list_clubs( status: Optional[str] = Query(default=None), - session=Depends(require_auth) + session=Depends(require_auth), ): """ - List all clubs (public for authenticated users). - - Filters: - - status: active, inactive + Vereine: für normale Nutzer nur Mitgliedschaft-Vereine; Plattform-Admins sehen alle. """ + role = session.get("role") + profile_id = session["profile_id"] with get_db() as conn: cur = get_cursor(conn) - query = "SELECT * FROM clubs" - params = [] + params: List[Any] = [] + conds = [] + + if not is_platform_admin(role): + cids = club_ids_for_profile(cur, profile_id) + if not cids: + return [] + conds.append("id IN (" + ",".join(["%s"] * len(cids)) + ")") + params.extend(sorted(cids)) if status: - query += " WHERE status = %s" + conds.append("status = %s") params.append(status) + if conds: + query += " WHERE " + " AND ".join(conds) + query += " ORDER BY name" cur.execute(query, params) @@ -44,9 +60,13 @@ def list_clubs( # ── Get Club ────────────────────────────────────────────────────────── @router.get("/clubs/{club_id}") def get_club(club_id: int, session=Depends(require_auth)): - """Get club by ID with divisions and groups.""" + """Get club by ID with divisions and groups – nur Mitglied oder Plattform-Admin.""" + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) + if not is_platform_admin(role): + assert_club_member(cur, profile_id, club_id) # Get club cur.execute("SELECT * FROM clubs WHERE id = %s", (club_id,)) @@ -58,23 +78,29 @@ def get_club(club_id: int, session=Depends(require_auth)): club = r2d(club) # Get divisions - cur.execute(""" + cur.execute( + """ SELECT * FROM divisions WHERE club_id = %s ORDER BY name - """, (club_id,)) - club['divisions'] = [r2d(r) for r in cur.fetchall()] + """, + (club_id,), + ) + club["divisions"] = [r2d(r) for r in cur.fetchall()] # Get training groups - cur.execute(""" + cur.execute( + """ SELECT g.*, p.name as trainer_name FROM training_groups g LEFT JOIN profiles p ON g.trainer_id = p.id WHERE g.club_id = %s ORDER BY g.weekday, g.time_start - """, (club_id,)) - club['training_groups'] = [r2d(r) for r in cur.fetchall()] + """, + (club_id,), + ) + club["training_groups"] = [r2d(r) for r in cur.fetchall()] return club @@ -82,30 +108,67 @@ def get_club(club_id: int, session=Depends(require_auth)): # ── Create Club ─────────────────────────────────────────────────────── @router.post("/clubs") def create_club(data: dict, session=Depends(require_auth)): - """Create new club (Admin oder Trainer — MVP ohne separates Vereins-Onboarding).""" - role = session.get('role') - if role not in ['admin', 'superadmin', 'trainer', 'user']: - raise HTTPException(403, "Keine Berechtigung, Vereine anzulegen") + """Neuen Verein anlegen – nur Plattform-Admin; Pflicht: primary_admin_profile_id (Hauptverwalter:in).""" + role = session.get("role") + if not is_platform_admin(role): + raise HTTPException(403, "Nur Plattform-Administratoren dürfen neue Vereine anlegen") - name = data.get('name') + name = data.get("name") + primary_admin_profile_id = data.get("primary_admin_profile_id") if not name: raise HTTPException(400, "Name ist Pflichtfeld") + if not primary_admin_profile_id: + raise HTTPException(400, "primary_admin_profile_id ist Pflichtfeld (Hauptverwalter:in)") + + try: + aid = int(primary_admin_profile_id) + except (TypeError, ValueError): + raise HTTPException(400, "primary_admin_profile_id ungültig") with get_db() as conn: cur = get_cursor(conn) + cur.execute("SELECT id FROM profiles WHERE id = %s", (aid,)) + if not cur.fetchone(): + raise HTTPException(404, "Profil für Hauptverwalter nicht gefunden") - cur.execute(""" - INSERT INTO clubs (name, abbreviation, description, status) - VALUES (%s, %s, %s, %s) + cur.execute( + """ + INSERT INTO clubs (name, abbreviation, description, status, primary_admin_profile_id) + VALUES (%s, %s, %s, %s, %s) RETURNING id - """, ( - name, - data.get('abbreviation'), - data.get('description'), - data.get('status', 'active') - )) + """, + ( + name, + data.get("abbreviation"), + data.get("description"), + data.get("status", "active"), + aid, + ), + ) + + club_id = cur.fetchone()["id"] + + cur.execute( + """ + INSERT INTO club_members (profile_id, club_id, status) + VALUES (%s, %s, 'active') + ON CONFLICT (profile_id, club_id) + DO UPDATE SET status = 'active', updated_at = NOW() + RETURNING id + """, + (aid, club_id), + ) + cm_id = cur.fetchone()["id"] + for rc in ("club_admin", "trainer"): + cur.execute( + """ + INSERT INTO club_member_roles (club_member_id, role_code) + VALUES (%s, %s) + ON CONFLICT (club_member_id, role_code) DO NOTHING + """, + (cm_id, rc), + ) - club_id = cur.fetchone()['id'] conn.commit() return get_club(club_id, session) @@ -114,10 +177,9 @@ def create_club(data: dict, session=Depends(require_auth)): # ── Update Club ─────────────────────────────────────────────────────── @router.put("/clubs/{club_id}") def update_club(club_id: int, data: dict, session=Depends(require_auth)): - """Update club (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Vereine bearbeiten") + """Verein bearbeiten – Plattform-Admin oder Vereinsadmin.""" + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) @@ -127,22 +189,35 @@ def update_club(club_id: int, data: dict, session=Depends(require_auth)): if not cur.fetchone(): raise HTTPException(404, "Verein nicht gefunden") + if not can_manage_club_org(cur, profile_id, club_id, role): + raise HTTPException(403, "Keine Berechtigung für diesen Verein") + + # Nur Plattform-Admin darf primary_admin_profile_id ändern + if "primary_admin_profile_id" in data and data["primary_admin_profile_id"] is not None: + if not is_platform_admin(role): + raise HTTPException(403, "Nur Plattform-Admins dürfen den Hauptverwalter ändern") + # Update - cur.execute(""" + cur.execute( + """ UPDATE clubs SET - name = %s, - abbreviation = %s, - description = %s, - status = %s, + name = COALESCE(%s, name), + abbreviation = COALESCE(%s, abbreviation), + description = COALESCE(%s, description), + status = COALESCE(%s, status), + primary_admin_profile_id = COALESCE(%s, primary_admin_profile_id), updated_at = NOW() WHERE id = %s - """, ( - data.get('name'), - data.get('abbreviation'), - data.get('description'), - data.get('status'), - club_id - )) + """, + ( + data.get("name"), + data.get("abbreviation"), + data.get("description"), + data.get("status"), + data.get("primary_admin_profile_id"), + club_id, + ), + ) conn.commit() @@ -176,22 +251,41 @@ def delete_club(club_id: int, session=Depends(require_auth)): @router.get("/divisions") def list_divisions( club_id: Optional[int] = Query(default=None), - session=Depends(require_auth) + session=Depends(require_auth), ): - """List divisions (optional filter by club).""" + """Sparten – ohne Admin-Rechte nur in eigenen Vereinen sichtbar.""" + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: cur = get_cursor(conn) + mine = club_ids_for_profile(cur, profile_id) + if not is_platform_admin(role) and not mine: + return [] + query = """ SELECT d.*, c.name as club_name FROM divisions d LEFT JOIN clubs c ON d.club_id = c.id """ + where = [] params = [] - if club_id: - query += " WHERE d.club_id = %s" + if club_id is not None: + where.append("d.club_id = %s") params.append(club_id) + if not is_platform_admin(role) and club_id not in mine: + return [] + + if not is_platform_admin(role): + where.append( + "d.club_id IN (" + ",".join(["%s"] * len(mine)) + ")" + ) + params.extend(sorted(mine)) + + if where: + query += " WHERE " + " AND ".join(where) query += " ORDER BY d.name" @@ -203,13 +297,12 @@ def list_divisions( # ── Create Division ─────────────────────────────────────────────────── @router.post("/divisions") def create_division(data: dict, session=Depends(require_auth)): - """Create new division (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Sparten erstellen") + """Create new division – Vereinsadmin / Plattform-Admin.""" + profile_id = session["profile_id"] + role = session.get("role") - club_id = data.get('club_id') - name = data.get('name') + club_id = data.get("club_id") + name = data.get("name") if not club_id or not name: raise HTTPException(400, "club_id und name sind Pflichtfelder") @@ -217,93 +310,109 @@ def create_division(data: dict, session=Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) + if not can_manage_club_org(cur, profile_id, int(club_id), role): + raise HTTPException(403, "Keine Berechtigung, Sparten in diesem Verein anzulegen") + # Check club exists cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,)) if not cur.fetchone(): raise HTTPException(404, "Verein nicht gefunden") # Insert - cur.execute(""" + cur.execute( + """ INSERT INTO divisions (club_id, name, focus_area) VALUES (%s, %s, %s) RETURNING id - """, ( - club_id, - name, - data.get('focus_area') - )) + """, + ( + club_id, + name, + data.get("focus_area"), + ), + ) - division_id = cur.fetchone()['id'] + division_id = cur.fetchone()["id"] conn.commit() # Return created division - cur.execute(""" + cur.execute( + """ SELECT d.*, c.name as club_name FROM divisions d LEFT JOIN clubs c ON d.club_id = c.id WHERE d.id = %s - """, (division_id,)) + """, + (division_id,), + ) return r2d(cur.fetchone()) # ── Update Division ─────────────────────────────────────────────────── @router.put("/divisions/{division_id}") def update_division(division_id: int, data: dict, session=Depends(require_auth)): - """Update division (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Sparten bearbeiten") + """Update division – Vereinsadmin / Plattform-Admin.""" + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - # Check existence - cur.execute("SELECT id FROM divisions WHERE id = %s", (division_id,)) - if not cur.fetchone(): + cur.execute("SELECT id, club_id FROM divisions WHERE id = %s", (division_id,)) + div = cur.fetchone() + if not div: raise HTTPException(404, "Sparte nicht gefunden") - # Update - cur.execute(""" + if not can_manage_club_org(cur, profile_id, div["club_id"], role): + raise HTTPException(403, "Keine Berechtigung") + + cur.execute( + """ UPDATE divisions SET name = %s, focus_area = %s, updated_at = NOW() WHERE id = %s - """, ( - data.get('name'), - data.get('focus_area'), - division_id - )) + """, + ( + data.get("name"), + data.get("focus_area"), + division_id, + ), + ) conn.commit() - # Return updated division - cur.execute(""" + cur.execute( + """ SELECT d.*, c.name as club_name FROM divisions d LEFT JOIN clubs c ON d.club_id = c.id WHERE d.id = %s - """, (division_id,)) + """, + (division_id,), + ) return r2d(cur.fetchone()) # ── Delete Division ─────────────────────────────────────────────────── @router.delete("/divisions/{division_id}") def delete_division(division_id: int, session=Depends(require_auth)): - """Delete division (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Sparten löschen") + """Delete division – Vereinsadmin / Plattform-Admin.""" + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - # Check existence - cur.execute("SELECT id FROM divisions WHERE id = %s", (division_id,)) - if not cur.fetchone(): + cur.execute("SELECT id, club_id FROM divisions WHERE id = %s", (division_id,)) + div = cur.fetchone() + if not div: raise HTTPException(404, "Sparte nicht gefunden") - # Delete + if not can_manage_club_org(cur, profile_id, div["club_id"], role): + raise HTTPException(403, "Keine Berechtigung") + cur.execute("DELETE FROM divisions WHERE id = %s", (division_id,)) conn.commit() @@ -316,19 +425,21 @@ def list_training_groups( club_id: Optional[int] = Query(default=None), division_id: Optional[int] = Query(default=None), status: Optional[str] = Query(default=None), - session=Depends(require_auth) + session=Depends(require_auth), ): """ - List training groups with optional filters. - - Filters: - - club_id: Filter by club - - division_id: Filter by division - - status: active, inactive + Trainingsgruppen – ohne Plattform-Admin nur in eigenen Vereinen. """ + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: cur = get_cursor(conn) + mine = club_ids_for_profile(cur, profile_id) + if not is_platform_admin(role) and not mine: + return [] + query = """ SELECT g.*, c.name as club_name, @@ -343,11 +454,13 @@ def list_training_groups( where = [] params = [] - if club_id: + if club_id is not None: where.append("g.club_id = %s") params.append(club_id) + if not is_platform_admin(role) and club_id not in mine: + return [] - if division_id: + if division_id is not None: where.append("g.division_id = %s") params.append(division_id) @@ -355,6 +468,10 @@ def list_training_groups( where.append("g.status = %s") params.append(status) + if not is_platform_admin(role): + where.append("g.club_id IN (" + ",".join(["%s"] * len(mine)) + ")") + params.extend(sorted(mine)) + if where: query += " WHERE " + " AND ".join(where) @@ -368,11 +485,14 @@ def list_training_groups( # ── Get Training Group ──────────────────────────────────────────────── @router.get("/groups/{group_id}") def get_training_group(group_id: int, session=Depends(require_auth)): - """Get training group by ID.""" + """Trainingsgruppe – nur mit Vereinszugriff.""" + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - cur.execute(""" + cur.execute( + """ SELECT g.*, c.name as club_name, d.name as division_name, @@ -382,45 +502,55 @@ def get_training_group(group_id: int, session=Depends(require_auth)): LEFT JOIN divisions d ON g.division_id = d.id LEFT JOIN profiles p ON g.trainer_id = p.id WHERE g.id = %s - """, (group_id,)) + """, + (group_id,), + ) group = cur.fetchone() if not group: raise HTTPException(404, "Trainingsgruppe nicht gefunden") + cid = group["club_id"] + if not is_platform_admin(role): + assert_club_member(cur, profile_id, cid) + return r2d(group) # ── Create Training Group ───────────────────────────────────────────── @router.post("/groups") def create_training_group(data: dict, session=Depends(require_auth)): - """Create new training group (admin, trainer oder normaler Nutzer mit Vereinsbezug).""" + """Trainingsgruppe anlegen – Mitglied mit Planungs-/Admin-Rolle im Verein.""" profile_id = session["profile_id"] - role = session.get('role') - if role not in ['admin', 'superadmin', 'trainer', 'user']: + role = session.get("role") + if role not in ["admin", "superadmin", "trainer", "user"]: raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen") - club_id = data.get('club_id') - name = data.get('name') + club_id = data.get("club_id") + name = data.get("name") if not club_id or not name: raise HTTPException(400, "club_id und name sind Pflichtfelder") - trainer_id = data.get('trainer_id') + trainer_id = data.get("trainer_id") if trainer_id in (None, "", 0) and role in ("trainer", "user"): trainer_id = profile_id with get_db() as conn: cur = get_cursor(conn) + if not can_plan_in_club(cur, profile_id, int(club_id), role): + raise HTTPException(403, "Keine Berechtigung für diesen Verein") + # Check club exists cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,)) if not cur.fetchone(): raise HTTPException(404, "Verein nicht gefunden") # Insert - cur.execute(""" + cur.execute( + """ INSERT INTO training_groups ( club_id, division_id, name, focus, level, age_group, weekday, time_start, time_end, location, @@ -430,23 +560,25 @@ def create_training_group(data: dict, session=Depends(require_auth)): %s, %s, %s, %s, %s, %s, %s ) RETURNING id - """, ( - club_id, - data.get('division_id'), - name, - data.get('focus'), - data.get('level'), - data.get('age_group'), - data.get('weekday'), - data.get('time_start'), - data.get('time_end'), - data.get('location'), - trainer_id, - data.get('co_trainer_ids'), - data.get('status', 'active') - )) + """, + ( + club_id, + data.get("division_id"), + name, + data.get("focus"), + data.get("level"), + data.get("age_group"), + data.get("weekday"), + data.get("time_start"), + data.get("time_end"), + data.get("location"), + trainer_id, + data.get("co_trainer_ids"), + data.get("status", "active"), + ), + ) - group_id = cur.fetchone()['id'] + group_id = cur.fetchone()["id"] conn.commit() return get_training_group(group_id, session) @@ -455,25 +587,35 @@ def create_training_group(data: dict, session=Depends(require_auth)): # ── Update Training Group ───────────────────────────────────────────── @router.put("/groups/{group_id}") def update_training_group(group_id: int, data: dict, session=Depends(require_auth)): - """Update training group (admin or assigned trainer).""" - profile_id = session['profile_id'] - role = session.get('role') + """Update training group – Vereinsadmin, Plattform-Admin oder zugewiesene Trainer.""" + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - # Check existence and ownership - cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (group_id,)) + cur.execute( + "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", + (group_id,), + ) row = cur.fetchone() if not row: raise HTTPException(404, "Trainingsgruppe nicht gefunden") - # Only admin or assigned trainer can update - if role not in ['admin', 'superadmin'] and row['trainer_id'] != profile_id: + co_trainers = row["co_trainer_ids"] or [] + club_id = row["club_id"] + + allowed = role in ["admin", "superadmin"] + if not allowed: + allowed = can_manage_club_org(cur, profile_id, club_id, role) + if not allowed: + allowed = row["trainer_id"] == profile_id or profile_id in co_trainers + + if not allowed: raise HTTPException(403, "Keine Berechtigung") - # Update - cur.execute(""" + cur.execute( + """ UPDATE training_groups SET name = %s, division_id = %s, @@ -489,21 +631,23 @@ def update_training_group(group_id: int, data: dict, session=Depends(require_aut status = %s, updated_at = NOW() WHERE id = %s - """, ( - data.get('name'), - data.get('division_id'), - data.get('focus'), - data.get('level'), - data.get('age_group'), - data.get('weekday'), - data.get('time_start'), - data.get('time_end'), - data.get('location'), - data.get('trainer_id'), - data.get('co_trainer_ids'), - data.get('status'), - group_id - )) + """, + ( + data.get("name"), + data.get("division_id"), + data.get("focus"), + data.get("level"), + data.get("age_group"), + data.get("weekday"), + data.get("time_start"), + data.get("time_end"), + data.get("location"), + data.get("trainer_id"), + data.get("co_trainer_ids"), + data.get("status"), + group_id, + ), + ) conn.commit() @@ -513,20 +657,21 @@ def update_training_group(group_id: int, data: dict, session=Depends(require_aut # ── Delete Training Group ───────────────────────────────────────────── @router.delete("/groups/{group_id}") def delete_training_group(group_id: int, session=Depends(require_auth)): - """Delete training group (admin only).""" - role = session.get('role') - if role not in ['admin', 'superadmin']: - raise HTTPException(403, "Nur Admins dürfen Gruppen löschen") + """Delete training group – Vereinsadmin oder Plattform-Admin.""" + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - # Check existence - cur.execute("SELECT id FROM training_groups WHERE id = %s", (group_id,)) - if not cur.fetchone(): + cur.execute("SELECT id, club_id FROM training_groups WHERE id = %s", (group_id,)) + row = cur.fetchone() + if not row: raise HTTPException(404, "Trainingsgruppe nicht gefunden") - # Delete + if not can_manage_club_org(cur, profile_id, row["club_id"], role): + raise HTTPException(403, "Keine Berechtigung") + cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,)) conn.commit() diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 9dbfa77..7bbfb3f 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -16,6 +16,7 @@ from pydantic import BaseModel, Field, model_validator from db import get_db, get_cursor, r2d from auth import require_auth +from club_tenancy import exercise_visible_to_profile, is_platform_admin logger = logging.getLogger(__name__) @@ -592,9 +593,24 @@ def list_exercises( where = ["1=1"] params = [] - # Visibility Filter (private nur für Owner) - where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)") - params.append(profile_id) + # Mandanten-Sichtbarkeit: official / eigene private / club nur eigene Vereine (Plattform-Admin: alles) + role = session.get("role") + if not is_platform_admin(role): + where.append( + """( + e.visibility = 'official' + OR (e.visibility = 'private' AND e.created_by = %s) + OR ( + e.visibility = 'club' + AND e.club_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s AND cm.club_id = e.club_id AND cm.status = 'active' + ) + ) + )""" + ) + params.extend([profile_id, profile_id]) vis_list = _merge_str_any(visibility_any, visibility) if vis_list: @@ -754,12 +770,18 @@ def get_exercise( cur = get_cursor(conn) exercise = enrich_exercise_detail(exercise_id, cur) - if not exercise: - raise HTTPException(status_code=404, detail="Übung nicht gefunden") + if not exercise: + raise HTTPException(status_code=404, detail="Übung nicht gefunden") - # Permission Check (private nur für Owner) - if exercise["visibility"] == "private" and exercise["created_by"] != profile_id: - raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung") + if not exercise_visible_to_profile( + cur, + profile_id, + exercise["visibility"], + exercise.get("club_id"), + exercise.get("created_by"), + session.get("role"), + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung") return exercise diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index c79b185..ca78d09 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d from auth import require_auth +from club_tenancy import assert_club_member, memberships_with_roles from models import ProfileCreate, ProfileUpdate router = APIRouter(prefix="/api", tags=["profiles"]) @@ -40,7 +41,10 @@ def get_current_profile(session=Depends(require_auth)): row = cur.fetchone() if not row: raise HTTPException(404, "Profil nicht gefunden") - return r2d(row) + data = r2d(row) + data.pop("pin_hash", None) + data["clubs"] = memberships_with_roles(cur, profile_id) + return data # ── Admin Profile Management ────────────────────────────────────────────────── @@ -127,10 +131,24 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): data["verification_token"] = None data["verification_expires"] = None + if "active_club_id" in patch: + ac = patch["active_club_id"] + if ac is None: + data["active_club_id"] = None + else: + try: + cid = int(ac) + except (TypeError, ValueError): + raise HTTPException(400, "active_club_id ungültig") + assert_club_member(cur, int(pid), cid) + data["active_club_id"] = cid + nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} for k, v in patch.items(): if k == "email": continue + if k == "active_club_id": + continue if v is None and k in nullable_keys: data[k] = None elif v is not None: diff --git a/backend/version.py b/backend/version.py index 00b7e14..e1c7330 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,17 +1,17 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.11" +APP_VERSION = "0.8.12" BUILD_DATE = "2026-05-05" -DB_SCHEMA_VERSION = "20260505038" +DB_SCHEMA_VERSION = "20260505039" MODULE_VERSIONS = { "auth": "1.0.0", - "profiles": "1.0.0", - "clubs": "0.1.0", + "profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id + "clubs": "0.2.0", "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034) + "exercises": "2.4.0", # Multi-Tenancy: club-Sichtbarkeit nur im eigenen Verein "training_units": "0.1.0", "training_programs": "0.1.0", "planning": "0.6.0", @@ -23,6 +23,17 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.12", + "date": "2026-05-05", + "changes": [ + "DB 039: club_members, club_member_roles, clubs.primary_admin_profile_id, profiles.active_club_id + Backfill", + "Multi-Tenancy: Vereinslisten gefiltert, Vereinsanlage nur Plattform-Admin mit primary_admin_profile_id", + "Übungen: visibility club nur für Mitglieder des zugeordneten Vereins", + "GET /api/profiles/me: clubs[], ohne pin_hash; active_club_id setzen via PUT", + "Frontend: X-Active-Club-Id, Vereins-Umschalter, Vereinsseiten angepasst", + ], + }, { "version": "0.8.11", "date": "2026-05-05", diff --git a/frontend/src/components/Navigation.jsx b/frontend/src/components/Navigation.jsx index 0871d43..8d7ccd9 100644 --- a/frontend/src/components/Navigation.jsx +++ b/frontend/src/components/Navigation.jsx @@ -4,9 +4,13 @@ import { useAuth } from '../context/AuthContext' function Navigation() { const location = useLocation() const navigate = useNavigate() - const { user, logout } = useAuth() + const { user, logout, setActiveClub } = useAuth() - const handleLogout = async () => { + const clubs = user?.clubs || [] + const selectClubId = + user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id) + ? user.active_club_id + : clubs[0]?.id const handleLogout = async () => { await logout() navigate('/login') } @@ -84,6 +88,25 @@ function Navigation() { Profil + {(clubs?.length ?? 0) > 1 && ( + + )} + {/* User Menu */}