From 0c044249d9a5684b9c87cb7bc56d6341ad7967c3 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 16:12:17 +0200 Subject: [PATCH 01/17] =?UTF-8?q?Phase=200a=20Mandantenf=C3=A4higkeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/functional/SHINKAN_REQUIREMENTS.md | 1 + .../MULTI_TENANCY_RBAC_ARCHITECTURE.md | 213 ++++++++ backend/club_tenancy.py | 127 +++++ backend/main.py | 2 + .../migrations/039_club_membership_rbac.sql | 114 +++++ backend/models.py | 1 + backend/routers/clubs.py | 461 ++++++++++++------ backend/routers/exercises.py | 38 +- backend/routers/profiles.py | 20 +- backend/version.py | 21 +- frontend/src/components/Navigation.jsx | 27 +- frontend/src/context/AuthContext.jsx | 47 +- frontend/src/pages/ClubsPage.jsx | 78 ++- frontend/src/utils/api.js | 21 +- frontend/src/version.js | 4 +- 15 files changed, 982 insertions(+), 193 deletions(-) create mode 100644 .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md create mode 100644 backend/club_tenancy.py create mode 100644 backend/migrations/039_club_membership_rbac.sql 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 */}
String(c.id))) + const stored = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) + if (stored && ids.has(stored)) return + + const ac = + profile?.active_club_id != null && profile.active_club_id !== '' + ? String(profile.active_club_id) + : '' + if (ac && ids.has(ac)) { + localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, ac) + return + } + if (clubs.length >= 1) { + localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(clubs[0].id)) + } +} + export function AuthProvider({ children }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) + const userRef = useRef(null) + + useEffect(() => { + userRef.current = user + }, [user]) const checkAuth = useCallback(async () => { const token = localStorage.getItem('authToken') @@ -16,6 +40,7 @@ export function AuthProvider({ children }) { try { const profile = await api.getCurrentProfile() + syncStoredActiveClub(profile) setUser(profile) } catch (err) { console.error('Auth check failed:', err) @@ -29,9 +54,23 @@ export function AuthProvider({ children }) { checkAuth() }, [checkAuth]) + const setActiveClub = useCallback(async (clubId) => { + const cid = Number(clubId) + const uid = userRef.current?.id + if (!Number.isFinite(cid) || cid < 1 || !uid) return + localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(cid)) + setUser((prev) => (prev?.id ? { ...prev, active_club_id: cid } : prev)) + try { + await api.updateProfile(uid, { active_club_id: cid }) + } catch (e) { + console.error(e) + } + }, []) + /** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */ const login = (payload) => { if (payload?.profile != null) { + syncStoredActiveClub(payload.profile) setUser(payload.profile) return } @@ -52,6 +91,7 @@ export function AuthProvider({ children }) { const logout = () => { setUser(null) localStorage.removeItem('authToken') + localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY) } const value = { @@ -60,7 +100,8 @@ export function AuthProvider({ children }) { loading, login, logout, - checkAuth + checkAuth, + setActiveClub, } return ( diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx index 0ccf9fb..70ae42c 100644 --- a/frontend/src/pages/ClubsPage.jsx +++ b/frontend/src/pages/ClubsPage.jsx @@ -16,10 +16,26 @@ function ClubsPage() { // Form state const [formData, setFormData] = useState({}) - const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' - const isTrainer = user?.role === 'trainer' || isAdmin - const canCreateClub = ['admin', 'superadmin', 'trainer', 'user'].includes(user?.role) + const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const isSuperAdmin = user?.role === 'superadmin' + const clubAdminClubIds = new Set( + (user?.clubs || []) + .filter((c) => (c.roles || []).includes('club_admin')) + .map((c) => c.id) + ) + const canManageClub = (clubId) => isPlatformAdmin || clubAdminClubIds.has(clubId) + const canCreateClub = isPlatformAdmin + const canManageOrgSomewhere = isPlatformAdmin || clubAdminClubIds.size > 0 + const canCreateTrainingGroup = + isPlatformAdmin || (Array.isArray(user?.clubs) && user.clubs.length > 0) + const canEditGroup = (g) => + isPlatformAdmin || + clubAdminClubIds.has(g.club_id) || + g.trainer_id === user?.id || + (Array.isArray(g.co_trainer_ids) && g.co_trainer_ids.includes(user?.id)) + + const canDeleteGroup = (g) => isPlatformAdmin || clubAdminClubIds.has(g.club_id) useEffect(() => { loadData() }, []) @@ -47,7 +63,13 @@ function ClubsPage() { setModalType(type) if (type === 'club') { - setFormData({ name: '', abbreviation: '', description: '', status: 'active' }) + setFormData({ + name: '', + abbreviation: '', + description: '', + status: 'active', + primary_admin_profile_id: user?.id ?? '', + }) } else if (type === 'division') { setFormData({ club_id: '', name: '', focus_area: '' }) } else if (type === 'group') { @@ -102,11 +124,19 @@ function ClubsPage() { e.preventDefault() try { - if (modalType === 'club') { + if (modalType === 'club') { if (editing) { await api.updateClub(editing.id, formData) } else { - await api.createClub(formData) + const payload = { + ...formData, + primary_admin_profile_id: Number(formData.primary_admin_profile_id), + } + if (!payload.primary_admin_profile_id) { + alert('Hauptverwalter (Profil-ID) ist Pflicht.') + return + } + await api.createClub(payload) } } else if (modalType === 'division') { if (editing) { @@ -235,7 +265,7 @@ function ClubsPage() { {club.status}
- {isAdmin && ( + {canManageClub(club.id) && (
+ {isSuperAdmin && ( + )}
)} @@ -269,7 +301,7 @@ function ClubsPage() { <>

Sparten

- {isAdmin && ( + {canManageOrgSomewhere && ( @@ -306,7 +338,7 @@ function ClubsPage() { )}
- {isAdmin && ( + {canManageClub(division.club_id) && (
@@ -377,7 +409,7 @@ function ClubsPage() { {group.age_group &&
👶 {group.age_group}
}
- {(isAdmin || group.trainer_id === user?.id) && ( + {canEditGroup(group) && (
- {isAdmin && ( + {canDeleteGroup(group) && (
+ + {!editing && canCreateClub && ( +
+ + + updateFormField( + 'primary_admin_profile_id', + e.target.value === '' ? '' : parseInt(e.target.value, 10) + ) + } + required + /> +

+ Nur Plattform-Administratoren legen Vereine an. Standard ist deine eigene Profil-ID. +

+
+ )} )} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 3fd207b..20c76f3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -8,6 +8,17 @@ import { stripHtmlToText } from './htmlUtils' const API_URL = import.meta.env.VITE_API_URL || '' +/** LocalStorage + Request-Header für Mandanten-Kontext */ +export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id' + +function mergeActiveClubHeader(headers = {}) { + const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY) + if (cid && /^\d+$/.test(String(cid).trim())) { + return { ...headers, 'X-Active-Club-Id': String(cid).trim() } + } + return { ...headers } +} + /** * Generic API request with automatic token injection */ @@ -15,9 +26,9 @@ async function request(endpoint, options = {}) { const token = localStorage.getItem('authToken') const method = (options.method || 'GET').toUpperCase() - const headers = { + const headers = mergeActiveClubHeader({ ...options.headers, - } + }) // GET ohne Body: kein Content-Type: application/json (manche Proxies/Headers stören sich) if (method !== 'GET' && method !== 'HEAD') { if (!headers['Content-Type'] && !headers['content-type']) { @@ -99,6 +110,11 @@ export async function getCurrentProfile() { return request('/api/profiles/me') } +/** Liste aller Profile – nur für Plattform-Admins (Vereinsanlage). */ +export async function listProfiles() { + return request('/api/profiles') +} + export async function updateProfile(profileId, data) { return request(`/api/profiles/${profileId}`, { method: 'PUT', @@ -1008,6 +1024,7 @@ export const api = { register, logout, getCurrentProfile, + listProfiles, updateProfile, changePassword, verifyEmail, diff --git a/frontend/src/version.js b/frontend/src/version.js index 630b724..d96640c 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.5.1" +export const APP_VERSION = "0.8.12" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = { @@ -8,7 +8,7 @@ export const PAGE_VERSIONS = { Dashboard: "1.0.0", AccountSettingsPage: "1.0.0", ExercisesPage: "1.1.0", // Updated: Katalog-Integration - ClubsPage: "1.0.0", + ClubsPage: "1.1.0", SkillsPage: "1.0.0", TrainingPlanningPage: "1.3.1", TrainingFrameworkProgramsListPage: "1.1.0", -- 2.43.0 From e69aca51f6f7d3f364b0d48274935aacceba0374 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 16:18:42 +0200 Subject: [PATCH 02/17] fix: update logging messages for database operations and version bump - Changed logging output for PostgreSQL readiness, schema loading, and migration status to a consistent format using [OK], [FAIL], and [WARN]. - Updated application version to 0.8.14 and modified changelog to reflect recent changes, including a fix for co-trainer backfill logic in the database migration. - Enhanced error handling messages for better clarity during migration processes. --- backend/db_init.py | 32 +++++++++---------- backend/main.py | 8 ++--- .../migrations/039_club_membership_rbac.sql | 5 +-- backend/run_migrations.py | 24 +++++++------- backend/version.py | 16 +++++++++- frontend/src/version.js | 2 +- 6 files changed, 51 insertions(+), 36 deletions(-) diff --git a/backend/db_init.py b/backend/db_init.py index 6714613..af6b9c9 100644 --- a/backend/db_init.py +++ b/backend/db_init.py @@ -32,13 +32,13 @@ def wait_for_postgres(max_retries=30): try: conn = get_connection() conn.close() - print("✓ PostgreSQL ready") + print("[OK] PostgreSQL ready") return True except OperationalError: print(f" Waiting for PostgreSQL... (attempt {i}/{max_retries})") time.sleep(2) - print(f"✗ PostgreSQL not ready after {max_retries} attempts") + print(f"[FAIL] PostgreSQL not ready after {max_retries} attempts") return False def check_table_exists(table_name="profiles"): @@ -71,10 +71,10 @@ def load_schema(schema_file="/app/schema.sql"): conn.commit() cur.close() conn.close() - print("✓ Schema loaded from schema.sql") + print("[OK] Schema loaded from schema.sql") return True except Exception as e: - print(f"✗ Error loading schema: {e}") + print(f"[FAIL] Error loading schema: {e}") return False def get_profile_count(): @@ -146,10 +146,10 @@ def apply_migration(filepath, filename): conn.commit() cur.close() conn.close() - print(f" ✓ Applied: {filename}") + print(f" [OK] Applied: {filename}") return True except Exception as e: - print(f" ✗ Failed to apply {filename}: {e}") + print(f" [FAIL] Failed to apply {filename}: {e}") return False def run_migrations(migrations_dir="/app/migrations"): @@ -158,7 +158,7 @@ def run_migrations(migrations_dir="/app/migrations"): import re if not os.path.exists(migrations_dir): - print("✓ No migrations directory found") + print("[OK] No migrations directory found") return True # Ensure migration tracking table exists @@ -174,7 +174,7 @@ def run_migrations(migrations_dir="/app/migrations"): migration_files = [f for f in all_files if migration_pattern.match(os.path.basename(f))] if not migration_files: - print("✓ No migration files found") + print("[OK] No migration files found") return True # Apply pending migrations @@ -185,7 +185,7 @@ def run_migrations(migrations_dir="/app/migrations"): pending.append((filepath, filename)) if not pending: - print(f"✓ All {len(applied)} migrations already applied") + print(f"[OK] All {len(applied)} migrations already applied") return True print(f" Found {len(pending)} pending migration(s)...") @@ -211,12 +211,12 @@ if __name__ == "__main__": if not load_schema(): sys.exit(1) else: - print("✓ Schema already exists") + print("[OK] Schema already exists") # Run migrations print("\nRunning database migrations...") if not run_migrations(): - print("✗ Migration failed") + print("[FAIL] Migration failed") sys.exit(1) # Check for migration @@ -232,14 +232,14 @@ if __name__ == "__main__": from migrate_to_postgres import main as migrate migrate() except Exception as e: - print(f"✗ Migration failed: {e}") + print(f"[FAIL] Migration failed: {e}") sys.exit(1) elif os.path.exists(sqlite_db) and profile_count > 0: - print(f"⚠ SQLite DB exists but PostgreSQL already has {profile_count} profiles") + print(f"[WARN] SQLite DB exists but PostgreSQL already has {profile_count} profiles") print(" Skipping migration (already migrated)") elif not os.path.exists(sqlite_db): - print("✓ No SQLite database found (fresh install or already migrated)") + print("[OK] No SQLite database found (fresh install or already migrated)") else: - print("✓ No migration needed") + print("[OK] No migration needed") - print("\n✓ Database initialization complete") + print("\n[OK] Database initialization complete") diff --git a/backend/main.py b/backend/main.py index d7a0ab9..a03c0b3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,20 +21,20 @@ from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS # Run database migrations before API start — halbes Schema ist schlimmer als kein Start # Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1 if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): - print("⚠ SKIP_DB_MIGRATE=1 — Migrationen wurden übersprungen (nur für Entwicklung ohne DB)") + print("[SKIP_DB_MIGRATE] Migrationen uebersprungen (nur fuer Entwicklung ohne DB)") else: try: import run_migrations rc = run_migrations.main() if rc != 0: - print(f"✗ Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.") + print(f"[FAIL] Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.") sys.exit(1) - print("✓ Database migrations completed") + print("[OK] Database migrations completed") except SystemExit: raise except Exception as e: - print(f"✗ Migration-Laufzeitfehler: {e}") + print(f"[FAIL] Migration-Laufzeitfehler: {e}") sys.exit(1) from routers.auth import limiter as auth_rate_limiter diff --git a/backend/migrations/039_club_membership_rbac.sql b/backend/migrations/039_club_membership_rbac.sql index 700187e..feee278 100644 --- a/backend/migrations/039_club_membership_rbac.sql +++ b/backend/migrations/039_club_membership_rbac.sql @@ -49,8 +49,9 @@ 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 +LATERAL jsonb_array_elements_text(t.co_trainer_ids) AS elem +WHERE jsonb_typeof(t.co_trainer_ids) = 'array' + AND jsonb_array_length(t.co_trainer_ids) > 0 ON CONFLICT (profile_id, club_id) DO NOTHING; INSERT INTO club_member_roles (club_member_id, role_code) diff --git a/backend/run_migrations.py b/backend/run_migrations.py index bd5f5d8..b68d5a5 100644 --- a/backend/run_migrations.py +++ b/backend/run_migrations.py @@ -49,14 +49,14 @@ def get_db_connection(): password=p["password"], ) conn.autocommit = False - print(f"✓ Connected to database: {p['dbname']}") + print(f"[OK] Connected to database: {p['dbname']}") return conn except psycopg2.OperationalError: if i < max_retries - 1: print(f"Waiting for database... ({i+1}/{max_retries})") time.sleep(2) else: - print(f"✗ Failed to connect to database after {max_retries} attempts") + print(f"[FAIL] Failed to connect to database after {max_retries} attempts") raise @@ -72,7 +72,7 @@ def init_migrations_table(conn): """ ) conn.commit() - print("✓ schema_migrations initialisiert") + print("[OK] schema_migrations initialisiert") _LEADING_DIGITS = re.compile(r"^(\d+)") @@ -190,7 +190,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: if shutil.which("psql"): ok, diag = _run_file_with_psql(filepath) if not ok: - print(f" ✗ psql fehlgeschlagen:\n{diag or '(kein Output)'}") + print(f" [FAIL] psql fehlgeschlagen:\n{diag or '(kein Output)'}") conn.rollback() return False detail_suffix = "(psql -1)" @@ -199,7 +199,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: with open(filepath, "r", encoding="utf-8") as fh: body = fh.read() except OSError as e: - print(f" ✗ kann Datei nicht lesen: {e}") + print(f" [FAIL] kann Datei nicht lesen: {e}") conn.rollback() return False @@ -207,7 +207,7 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: with conn.cursor() as cur: if not statements: print( - f" ⚠ keine ausführbaren Statements (leer?) — " + f" [WARN] keine ausführbaren Statements (leer?) — " f"Eintrag trotzdem: {migration_name}" ) else: @@ -217,12 +217,12 @@ def run_migration(conn, migration_name: str, filepath: str) -> bool: _record_migration(conn, migration_name) conn.commit() - print(f" ✓ {migration_name} erfolgreich {detail_suffix}") + print(f" [OK] {migration_name} erfolgreich {detail_suffix}") return True except Exception as e: conn.rollback() - print(f" ✗ {migration_name}: {e}") + print(f" [FAIL] {migration_name}: {e}") return False @@ -243,7 +243,7 @@ def main(): pending = get_pending(conn, migrations_dir) if not pending: - print("✓ Keine ausstehenden Migrationen — Schema aktuell.") + print("[OK] Keine ausstehenden Migrationen — Schema aktuell.") conn.close() return 0 @@ -262,17 +262,17 @@ def main(): print("\n" + "=" * 60) if failed: - print(f"✗ Abbruch nach: {failed}") + print(f"[FAIL] Abbruch nach: {failed}") print(" (Bereits erfolgreiche Dateien dieser Session sind committed.)") print("=" * 60) return 1 - print(f"✓ {len(pending)} Migration(s) angewendet — Schema aktuell.") + print(f"[OK] {len(pending)} Migration(s) angewendet — Schema aktuell.") print("=" * 60) return 0 except Exception as e: - print(f"\n✗ Fehler: {e}") + print(f"\n[FAIL] Fehler: {e}") return 1 diff --git a/backend/version.py b/backend/version.py index e1c7330..0539da1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.12" +APP_VERSION = "0.8.14" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505039" @@ -23,6 +23,20 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.14", + "date": "2026-05-05", + "changes": [ + "DB 039 Fix: Co-Trainer-Backfill nur wenn co_trainer_ids ein JSON-Array ist (vermeidet jsonb_array_length auf Nicht-Array)", + ], + }, + { + "version": "0.8.13", + "date": "2026-05-05", + "changes": [ + "Fix: Startup unter Windows (cp1252) — Emoji/Sonderzeichen in print durch ASCII ([OK]/[FAIL]/[WARN]) ersetzt (main, run_migrations, db_init)", + ], + }, { "version": "0.8.12", "date": "2026-05-05", diff --git a/frontend/src/version.js b/frontend/src/version.js index d96640c..efcbfce 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.12" +export const APP_VERSION = "0.8.14" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = { -- 2.43.0 From c7bf7dcd9d5c055b33e94203848ac5591daa8c32 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 16:20:16 +0200 Subject: [PATCH 03/17] chore: bump version to 0.8.15 and update changelog - Updated application version to 0.8.15 in both backend and frontend files. - Enhanced database migration logic for co-trainer backfill using a subquery and CASE statement, improving robustness. - Updated changelog to reflect the new version and changes made. --- backend/migrations/039_club_membership_rbac.sql | 14 +++++++++----- backend/version.py | 9 ++++++++- frontend/src/version.js | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/backend/migrations/039_club_membership_rbac.sql b/backend/migrations/039_club_membership_rbac.sql index feee278..7c9ae34 100644 --- a/backend/migrations/039_club_membership_rbac.sql +++ b/backend/migrations/039_club_membership_rbac.sql @@ -47,11 +47,15 @@ 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(t.co_trainer_ids) AS elem -WHERE jsonb_typeof(t.co_trainer_ids) = 'array' - AND jsonb_array_length(t.co_trainer_ids) > 0 +SELECT DISTINCT elem::int, x.club_id, 'active' +FROM ( + SELECT club_id, co_trainer_ids + FROM training_groups + WHERE CASE WHEN jsonb_typeof(co_trainer_ids) = 'array' + THEN jsonb_array_length(co_trainer_ids) + ELSE 0 END > 0 +) x, +LATERAL jsonb_array_elements_text(x.co_trainer_ids) AS elem ON CONFLICT (profile_id, club_id) DO NOTHING; INSERT INTO club_member_roles (club_member_id, role_code) diff --git a/backend/version.py b/backend/version.py index 0539da1..14e0108 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.14" +APP_VERSION = "0.8.15" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505039" @@ -23,6 +23,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.15", + "date": "2026-05-05", + "changes": [ + "DB 039 Fix: Co-Trainer-Backfill über Subquery + CASE (kein jsonb_array_length/jsonb_array_elements auf Nicht-Arrays durch Planner/LATERAL-Reihenfolge)", + ], + }, { "version": "0.8.14", "date": "2026-05-05", diff --git a/frontend/src/version.js b/frontend/src/version.js index efcbfce..a3383e9 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.14" +export const APP_VERSION = "0.8.15" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = { -- 2.43.0 From 7d476268b8306b0c0743cd8291df0447a70fa361 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 16:29:11 +0200 Subject: [PATCH 04/17] feat: implement multi-tenancy governance and enhance club member management - Bumped application version to 0.8.17, reflecting updates in both backend and frontend. - Introduced governance visibility for training plan templates and framework programs, allowing access based on visibility settings (private, club, official). - Added API endpoints for managing club members, including listing, adding, updating, and removing members. - Updated changelog to document the new features and changes made in this release. --- .../MULTI_TENANCY_RBAC_ARCHITECTURE.md | 6 +- backend/club_tenancy.py | 24 ++ backend/main.py | 3 +- backend/routers/club_memberships.py | 268 ++++++++++++++++++ .../routers/training_framework_programs.py | 88 +++++- backend/routers/training_planning.py | 101 +++++-- backend/version.py | 21 +- frontend/src/utils/api.js | 33 +++ frontend/src/version.js | 2 +- 9 files changed, 501 insertions(+), 45 deletions(-) create mode 100644 backend/routers/club_memberships.py diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md index 244ef6e..1f50e47 100644 --- a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md +++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md @@ -172,14 +172,16 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie ### Phase 3 – Effektive Berechtigungen (RBAC) +- **Mitgliederverwaltung per API (ohne UI):** `GET/POST /api/clubs/{club_id}/members`, `GET/PUT/DELETE /api/clubs/{club_id}/members/{profile_id}` — nur Plattform-Admin oder `club_admin` im Zielverein (Stand Code **0.8.16**). - Zentrale Modulfunktion z. B. `authorization/club_permissions.py`: - `can(club_id, profile_id, permission, division_id=None)`. + `can(club_id, profile_id, permission, division_id=None)` — optional später; aktuell `club_tenancy.can_manage_club_org` / `has_club_role`. - 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. +- **Trainingsplan-Vorlagen** (`training_plan_templates`) und **Rahmenprogramme** (`training_framework_programs`): gleiches Muster für Listen/GET (Stand **0.8.17**); Schreiben weiterhin nur Ersteller oder Plattform-Admin. +- Gleiches Muster für Progressionsgraphen, ggf. Medien (offen). - Tests: zwei Vereine, zwei Nutzer, keine Kreuzzugriffe. ### Phase 5 – Mitgliedschaft / Limits diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index 21f954b..ff731f5 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -96,6 +96,30 @@ def memberships_with_roles(cur, profile_id: int) -> List[Dict[str, Any]]: return out +_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"}) + + +def assert_valid_governance_visibility( + cur, + profile_id: int, + role: Optional[str], + visibility: str, + club_id: Optional[int], +) -> None: + """Pflicht club_id bei visibility=club + Mitgliedschaft; official nur Plattform-Admin.""" + if visibility not in _GOVERNANCE_VISIBILITY: + raise HTTPException(status_code=400, detail="Ungültige visibility") + if visibility == "official" and not is_platform_admin(role): + raise HTTPException( + status_code=403, + detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen", + ) + if visibility == "club": + if club_id is None: + raise HTTPException(status_code=400, detail="club_id ist bei visibility=club erforderlich") + assert_club_member(cur, profile_id, club_id) + + def exercise_visible_to_profile( cur, profile_id: int, diff --git a/backend/main.py b/backend/main.py index a03c0b3..27fffbb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -154,13 +154,14 @@ def read_root(): } # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin app.include_router(auth.router) app.include_router(profiles.router) app.include_router(exercises.router) app.include_router(exercise_progression_graphs.router) app.include_router(clubs.router) +app.include_router(club_memberships.router) app.include_router(skills.router) app.include_router(training_planning.router) app.include_router(training_framework_programs.router) diff --git a/backend/routers/club_memberships.py b/backend/routers/club_memberships.py new file mode 100644 index 0000000..7b2e22a --- /dev/null +++ b/backend/routers/club_memberships.py @@ -0,0 +1,268 @@ +""" +Vereins-Mitgliedschaften und Rollen (ohne UI nutzbar für Admin/Automatisierung). + +Berechtigung: Plattform-Admin (admin/superadmin) oder Vereinsadmin (club_admin) im Zielverein. +""" +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from auth import require_auth +from club_tenancy import can_manage_club_org +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api", tags=["club_memberships"]) + +_ALLOWED_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"}) +_ALLOWED_STATUS = frozenset({"active", "inactive"}) + + +def _normalize_roles(raw: List[str]) -> List[str]: + out: List[str] = [] + seen = set() + for r in raw: + if not isinstance(r, str): + raise HTTPException(status_code=400, detail="Rollen müssen Strings sein") + code = r.strip().lower() + if not code: + continue + if code not in _ALLOWED_ROLES: + raise HTTPException( + status_code=400, + detail=f"Unbekannte Rolle: {code}. Erlaubt: {', '.join(sorted(_ALLOWED_ROLES))}", + ) + if code not in seen: + seen.add(code) + out.append(code) + return out + + +def _assert_manage(cur, session: dict, club_id: int) -> None: + pid = session["profile_id"] + role = session.get("role") + if not can_manage_club_org(cur, pid, club_id, role): + raise HTTPException(status_code=403, detail="Keine Berechtigung zur Mitglieder-Verwaltung in diesem Verein") + + +def _club_exists(cur, club_id: int) -> bool: + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + return cur.fetchone() is not None + + +class ClubMemberUpsert(BaseModel): + profile_id: int = Field(..., ge=1) + roles: List[str] = Field(default_factory=list, description="Mindestens eine Vereinsrolle") + + +class ClubMemberPatch(BaseModel): + roles: Optional[List[str]] = None + status: Optional[str] = Field(None, description="active oder inactive") + + +@router.get("/clubs/{club_id}/members") +def list_club_members( + club_id: int, + include_inactive: bool = Query(False), + session: dict = Depends(require_auth), +): + """Alle Mitglieder eines Vereins mit Rollen (nur Vereins-/Plattform-Admin).""" + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, session, club_id) + + status_clause = "" if include_inactive else "AND cm.status = 'active'" + cur.execute( + f""" + SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, + p.email, p.name, + 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 profiles p ON p.id = cm.profile_id + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.club_id = %s {status_clause} + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name + ORDER BY p.name NULLS LAST, p.email + """, + (club_id,), + ) + rows = [] + for row in cur.fetchall(): + d = r2d(row) + roles = d.get("roles") or [] + if hasattr(roles, "tolist"): + roles = roles.tolist() + d["roles"] = list(roles) + rows.append(d) + return rows + + +@router.post("/clubs/{club_id}/members", status_code=201) +def upsert_club_member( + club_id: int, + body: ClubMemberUpsert, + session: dict = Depends(require_auth), +): + """Mitglied anlegen oder aktivieren; Rollen werden vollständig ersetzt.""" + roles = _normalize_roles(body.roles) + if not roles: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, session, club_id) + + cur.execute("SELECT id FROM profiles WHERE id = %s", (body.profile_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Profil nicht gefunden") + + 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 + """, + (body.profile_id, club_id), + ) + cm_id = cur.fetchone()["id"] + + cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) + for rc in roles: + 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), + ) + conn.commit() + return _one_member(cur, club_id, body.profile_id) + + +def _one_member(cur, club_id: int, profile_id: int) -> Dict[str, Any]: + cur.execute( + """ + SELECT cm.id AS membership_id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, + p.email, p.name, + 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 profiles p ON p.id = cm.profile_id + LEFT JOIN club_member_roles r ON r.club_member_id = cm.id + WHERE cm.club_id = %s AND cm.profile_id = %s + GROUP BY cm.id, cm.profile_id, cm.status, cm.created_at, cm.updated_at, p.email, p.name + """, + (club_id, profile_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden") + d = r2d(row) + roles = d.get("roles") or [] + if hasattr(roles, "tolist"): + roles = roles.tolist() + d["roles"] = list(roles) + return d + + +@router.get("/clubs/{club_id}/members/{profile_id}") +def get_club_member( + club_id: int, + profile_id: int, + session: dict = Depends(require_auth), +): + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, session, club_id) + return _one_member(cur, club_id, profile_id) + + +@router.put("/clubs/{club_id}/members/{profile_id}") +def update_club_member( + club_id: int, + profile_id: int, + body: ClubMemberPatch, + session: dict = Depends(require_auth), +): + """Rollen ersetzen und/oder Status setzen.""" + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, session, club_id) + + cur.execute( + "SELECT id FROM club_members WHERE club_id = %s AND profile_id = %s", + (club_id, profile_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden") + cm_id = row["id"] + + if body.roles is None and body.status is None: + return _one_member(cur, club_id, profile_id) + + if body.status is not None: + st = body.status.strip().lower() + if st not in _ALLOWED_STATUS: + raise HTTPException(status_code=400, detail="status muss active oder inactive sein") + cur.execute( + "UPDATE club_members SET status = %s, updated_at = NOW() WHERE id = %s", + (st, cm_id), + ) + + if body.roles is not None: + roles = _normalize_roles(body.roles) + if not roles: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) + for rc in roles: + 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), + ) + + conn.commit() + return _one_member(cur, club_id, profile_id) + + +@router.delete("/clubs/{club_id}/members/{profile_id}") +def delete_club_member( + club_id: int, + profile_id: int, + session: dict = Depends(require_auth), +): + """Mitgliedschaft löschen (Rollen per CASCADE).""" + with get_db() as conn: + cur = get_cursor(conn) + if not _club_exists(cur, club_id): + raise HTTPException(status_code=404, detail="Verein nicht gefunden") + _assert_manage(cur, session, club_id) + + cur.execute( + "DELETE FROM club_members WHERE club_id = %s AND profile_id = %s RETURNING id", + (club_id, profile_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Mitgliedschaft nicht gefunden") + conn.commit() + return {"ok": True} diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 459f7f1..954d693 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -3,13 +3,18 @@ Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage), nicht über group_id oder training_unit_id am Rahmen. -AuthZ wie Planungs-Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle. +Lesen wie Übungen (official / private / club); Schreiben nur Ersteller oder Plattform-Admin. """ from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, HTTPException from auth import require_auth +from club_tenancy import ( + assert_valid_governance_visibility, + exercise_visible_to_profile, + is_platform_admin, +) from db import get_db, get_cursor, r2d from routers.training_planning import ( @@ -25,16 +30,40 @@ router = APIRouter(prefix="/api", tags=["training_framework_programs"]) _VALID_VISIBILITY = frozenset({"private", "club", "official"}) -def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: +def _fetch_framework_row(cur, framework_id: int) -> Dict[str, Any]: cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,)) r = cur.fetchone() if not r: raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden") - row = r2d(r) - if role in ("admin", "superadmin"): - return row + return r2d(r) + + +def _framework_assert_readable( + cur, row: Dict[str, Any], profile_id: int, role: Optional[str] +) -> None: + if is_platform_admin(role): + return + if not exercise_visible_to_profile( + cur, + profile_id, + row.get("visibility") or "private", + row.get("club_id"), + row.get("created_by"), + role, + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen") + + +def _framework_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: + if is_platform_admin(role): + return if row.get("created_by") != profile_id: raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen") + + +def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: + row = _fetch_framework_row(cur, framework_id) + _framework_assert_readable(cur, row, profile_id, role) return row @@ -312,12 +341,25 @@ def list_training_framework_programs(session=Depends(require_auth)): LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id """ - if role in ("admin", "superadmin"): + if is_platform_admin(role): cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title") else: cur.execute( - base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title", - (profile_id,), + base_sel + + """ WHERE ( + fp.visibility = 'official' + OR (fp.visibility = 'private' AND fp.created_by = %s) + OR ( + fp.visibility = 'club' + AND fp.club_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s AND cm.club_id = fp.club_id AND cm.status = 'active' + ) + ) + ) + ORDER BY fp.updated_at DESC NULLS LAST, fp.title""", + (profile_id, profile_id), ) return [r2d(r) for r in cur.fetchall()] @@ -346,6 +388,8 @@ def create_training_framework_program(data: dict, session=Depends(require_auth)) vis = data.get("visibility") or "private" vis = _assert_visibility(vis) club_id = data.get("club_id") + if club_id in ("", []): + club_id = None goals_in = data.get("goals") slots_in = data.get("slots") if not isinstance(goals_in, list) or not goals_in: @@ -358,6 +402,7 @@ def create_training_framework_program(data: dict, session=Depends(require_auth)) with get_db() as conn: cur = get_cursor(conn) + assert_valid_governance_visibility(cur, profile_id, role, vis, club_id) cur.execute( """ INSERT INTO training_framework_programs ( @@ -399,7 +444,22 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep with get_db() as conn: cur = get_cursor(conn) - _framework_access(cur, framework_id, profile_id, role) + row_prev = _fetch_framework_row(cur, framework_id) + _framework_assert_writable(cur, row_prev, profile_id, role) + + merged_vis = row_prev.get("visibility") or "private" + merged_club = row_prev.get("club_id") + if "visibility" in data: + v_m = _assert_visibility(data.get("visibility")) + if v_m is None: + raise HTTPException(status_code=400, detail="visibility fehlt") + merged_vis = v_m + if "club_id" in data: + merged_club = data.get("club_id") + if merged_club in ("", []): + merged_club = None + if "visibility" in data or "club_id" in data: + assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club) header_fields = [] header_params: List[Any] = [] @@ -422,14 +482,11 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep header_params.append(data.get("planned_period_end")) if "visibility" in data: - v = _assert_visibility(data.get("visibility")) - if v is None: - raise HTTPException(status_code=400, detail="visibility fehlt") header_fields.append("visibility = %s") - header_params.append(v) + header_params.append(merged_vis) if "club_id" in data: header_fields.append("club_id = %s") - header_params.append(data.get("club_id")) + header_params.append(merged_club) if "focus_area_id" in data: fidv = data.get("focus_area_id") @@ -498,7 +555,8 @@ def delete_training_framework_program(framework_id: int, session=Depends(require role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - _framework_access(cur, framework_id, profile_id, role) + row_fw = _fetch_framework_row(cur, framework_id) + _framework_assert_writable(cur, row_fw, profile_id, role) cur.execute( "DELETE FROM training_framework_programs WHERE id = %s", (framework_id,), diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 2cd7f03..e5bd24e 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -2,7 +2,7 @@ Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen) und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung). -Governance (Vorlagen-rechte über Vereine/„offiziell“) kann später nachgezogen werden. +Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. """ from typing import Any, Dict, List, Optional @@ -10,6 +10,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from db import get_db, get_cursor, r2d from auth import require_auth +from club_tenancy import ( + assert_valid_governance_visibility, + exercise_visible_to_profile, + is_platform_admin, +) router = APIRouter(prefix="/api", tags=["training_planning"]) @@ -515,23 +520,39 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int): ) -def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]: - cur.execute( - """ - SELECT * - FROM training_plan_templates - WHERE id = %s - """, - (tid,), - ) +def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]: + cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,)) r = cur.fetchone() if not r: raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden") - row = r2d(r) - if role in ["admin", "superadmin"]: - return row - if row["created_by"] != profile_id: + return r2d(r) + + +def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: + if is_platform_admin(role): + return + if not exercise_visible_to_profile( + cur, + profile_id, + row.get("visibility") or "club", + row.get("club_id"), + row.get("created_by"), + role, + ): raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage") + + +def _template_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: + if is_platform_admin(role): + return + if row.get("created_by") != profile_id: + raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Vorlage ändern") + + +def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]: + """Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable.""" + row = _fetch_training_plan_template_row(cur, tid) + _template_assert_readable(cur, row, profile_id, role) return row @@ -544,7 +565,7 @@ def list_training_plan_templates(session=Depends(require_auth)): role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - if role in ["admin", "superadmin"]: + if is_platform_admin(role): cur.execute( """ SELECT t.*, @@ -561,10 +582,21 @@ def list_training_plan_templates(session=Depends(require_auth)): (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) AS sections_count FROM training_plan_templates t - WHERE t.created_by = %s + WHERE ( + t.visibility = 'official' + OR (t.visibility = 'private' AND t.created_by = %s) + OR ( + t.visibility = 'club' + AND t.club_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s AND cm.club_id = t.club_id AND cm.status = 'active' + ) + ) + ) ORDER BY t.updated_at DESC NULLS LAST, t.name """, - (profile_id,), + (profile_id, profile_id), ) return [r2d(r) for r in cur.fetchall()] @@ -598,18 +630,23 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)): name = (data.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name ist Pflicht") + vis_raw = data.get("visibility") + visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club" club_id = data.get("club_id") + if club_id in ("", []): + club_id = None sections_in = data.get("sections") or [] with get_db() as conn: cur = get_cursor(conn) + assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id) cur.execute( """ - INSERT INTO training_plan_templates (club_id, created_by, name, description) - VALUES (%s, %s, %s, %s) + INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility) + VALUES (%s, %s, %s, %s, %s) RETURNING id """, - (club_id, profile_id, name, data.get("description")), + (club_id, profile_id, name, data.get("description"), visibility), ) tid = cur.fetchone()["id"] for si, sec in enumerate(sections_in): @@ -634,7 +671,21 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends( role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - _template_access(cur, template_id, profile_id, role) + row_prev = _fetch_training_plan_template_row(cur, template_id) + _template_assert_writable(cur, row_prev, profile_id, role) + merged_vis = row_prev.get("visibility") or "club" + merged_club = row_prev.get("club_id") + if "visibility" in data: + v_in = data.get("visibility") + if not isinstance(v_in, str) or v_in not in ("private", "club", "official"): + raise HTTPException(status_code=400, detail="visibility ungültig") + merged_vis = v_in + if "club_id" in data: + merged_club = data.get("club_id") + if merged_club in ("", []): + merged_club = None + if "visibility" in data or "club_id" in data: + assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club) fields = [] params: List[Any] = [] if "name" in data: @@ -649,7 +700,10 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends( params.append(data.get("description")) if "club_id" in data: fields.append("club_id = %s") - params.append(data.get("club_id")) + params.append(merged_club) + if "visibility" in data: + fields.append("visibility = %s") + params.append(merged_vis) fields.append("updated_at = NOW()") params.append(template_id) cur.execute( @@ -686,7 +740,8 @@ def delete_training_plan_template(template_id: int, session=Depends(require_auth role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - _template_access(cur, template_id, profile_id, role) + row_del = _fetch_training_plan_template_row(cur, template_id) + _template_assert_writable(cur, row_del, profile_id, role) cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,)) conn.commit() return {"ok": True} diff --git a/backend/version.py b/backend/version.py index 14e0108..4a8a9d1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,20 +1,21 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.15" +APP_VERSION = "0.8.17" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505039" MODULE_VERSIONS = { "auth": "1.0.0", "profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id - "clubs": "0.2.0", + "clubs": "0.3.0", + "club_memberships": "1.0.0", "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", "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", + "planning": "0.7.0", # Vorlagen + Rahmenprogramme: Listen/GET wie Übungen (visibility/club); Governance-Validierung "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -23,6 +24,20 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.17", + "date": "2026-05-05", + "changes": [ + "Multi-Tenancy Phase 4: training_plan_templates + training_framework_programs Listen und Lesen nach visibility/club wie Übungen; Schreiben nur Ersteller oder Plattform-Admin; club_tenancy.assert_valid_governance_visibility", + ], + }, + { + "version": "0.8.16", + "date": "2026-05-05", + "changes": [ + "API Vereinsmitglieder: GET/POST/GET-one/PUT/DELETE /api/clubs/{id}/members (Plattform- oder Vereinsadmin); Frontend api.js Hooks", + ], + }, { "version": "0.8.15", "date": "2026-05-05", diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 20c76f3..279b8b6 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -174,6 +174,34 @@ export async function deleteClub(id) { return request(`/api/clubs/${id}`, { method: 'DELETE' }) } +/** Vereinsmitglieder (API für Admin ohne eigene UI) */ +export async function listClubMembers(clubId, { includeInactive = false } = {}) { + const q = includeInactive ? '?include_inactive=true' : '' + return request(`/api/clubs/${clubId}/members${q}`) +} + +export async function getClubMember(clubId, profileId) { + return request(`/api/clubs/${clubId}/members/${profileId}`) +} + +export async function addClubMember(clubId, payload) { + return request(`/api/clubs/${clubId}/members`, { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export async function updateClubMember(clubId, profileId, payload) { + return request(`/api/clubs/${clubId}/members/${profileId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }) +} + +export async function removeClubMember(clubId, profileId) { + return request(`/api/clubs/${clubId}/members/${profileId}`, { method: 'DELETE' }) +} + export async function listDivisions(clubId) { const query = clubId ? `?club_id=${clubId}` : '' return request(`/api/divisions${query}`) @@ -1036,6 +1064,11 @@ export const api = { createClub, updateClub, deleteClub, + listClubMembers, + getClubMember, + addClubMember, + updateClubMember, + removeClubMember, listDivisions, createDivision, updateDivision, diff --git a/frontend/src/version.js b/frontend/src/version.js index a3383e9..f3a1220 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.15" +export const APP_VERSION = "0.8.17" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = { -- 2.43.0 From 0f08e8df5804e1c033e6de3e96d8fe9e4d1402d8 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 16:40:49 +0200 Subject: [PATCH 05/17] feat: enhance club management features and member requests - Updated the backend to include a new `requested_club_id` field in the registration request model. - Replaced the club memberships router with a new club join requests router for better management of membership applications. - Added API endpoints for listing public clubs and managing club join requests, improving user experience during registration and membership processes. - Enhanced the ClubsPage in the frontend to support member management and join requests, including new modals for adding members and handling requests. - Updated API utility functions to accommodate new endpoints for club join requests and public club listings. --- backend/main.py | 4 +- .../040_club_membership_requests.sql | 29 + backend/models.py | 1 + backend/routers/auth.py | 26 + backend/routers/club_join_requests.py | 281 ++++++++ backend/routers/clubs.py | 42 ++ backend/routers/profiles.py | 14 +- backend/version.py | 20 +- frontend/src/pages/AccountSettingsPage.jsx | 156 +++++ frontend/src/pages/ClubsPage.jsx | 662 +++++++++++++++++- frontend/src/pages/LoginPage.jsx | 38 +- frontend/src/utils/api.js | 57 +- frontend/src/version.js | 2 +- 13 files changed, 1313 insertions(+), 19 deletions(-) create mode 100644 backend/migrations/040_club_membership_requests.sql create mode 100644 backend/routers/club_join_requests.py diff --git a/backend/main.py b/backend/main.py index 27fffbb..c8c90b5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -154,14 +154,14 @@ def read_root(): } # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin app.include_router(auth.router) app.include_router(profiles.router) app.include_router(exercises.router) app.include_router(exercise_progression_graphs.router) app.include_router(clubs.router) -app.include_router(club_memberships.router) +app.include_router(club_join_requests.router) app.include_router(skills.router) app.include_router(training_planning.router) app.include_router(training_framework_programs.router) diff --git a/backend/migrations/040_club_membership_requests.sql b/backend/migrations/040_club_membership_requests.sql new file mode 100644 index 0000000..976981a --- /dev/null +++ b/backend/migrations/040_club_membership_requests.sql @@ -0,0 +1,29 @@ +-- Migration 040: Antrag auf Vereinsbeitritt (pending → accept/reject durch Vereins-/Plattform-Admin) + +CREATE TABLE IF NOT EXISTS club_membership_requests ( + 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 'pending' + CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')), + message TEXT, + decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + decided_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_club_membership_requests_pending + ON club_membership_requests (profile_id, club_id) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_club_membership_requests_club_status + ON club_membership_requests (club_id, status); + +CREATE INDEX IF NOT EXISTS idx_club_membership_requests_profile + ON club_membership_requests (profile_id); + +DROP TRIGGER IF EXISTS club_membership_requests_update ON club_membership_requests; +CREATE TRIGGER club_membership_requests_update + BEFORE UPDATE ON club_membership_requests + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); diff --git a/backend/models.py b/backend/models.py index f8c9cb9..ecae0d7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -19,6 +19,7 @@ class RegisterRequest(BaseModel): email: EmailStr password: str name: Optional[str] = None + requested_club_id: Optional[int] = Field(default=None, ge=1) class PasswordResetRequest(BaseModel): email: str diff --git a/backend/routers/auth.py b/backend/routers/auth.py index b740576..bdfa1e3 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -302,7 +302,33 @@ async def register(req: RegisterRequest, request: Request): email_verified, verification_token, verification_expires, trial_ends_at, created_at ) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) + RETURNING id """, (name, email, pin_hash, role, verification_token, verification_expires, trial_ends)) + new_profile_id = cur.fetchone()["id"] + + req_club = req.requested_club_id + if req_club is not None: + cur.execute( + "SELECT id FROM clubs WHERE id = %s AND status = 'active'", + (int(req_club),), + ) + if cur.fetchone(): + cur.execute( + """ + SELECT id FROM club_membership_requests + WHERE profile_id = %s AND club_id = %s AND status = 'pending' + LIMIT 1 + """, + (new_profile_id, int(req_club)), + ) + if not cur.fetchone(): + cur.execute( + """ + INSERT INTO club_membership_requests (profile_id, club_id, status, message) + VALUES (%s, %s, 'pending', NULL) + """, + (new_profile_id, int(req_club)), + ) verify_url = verification_link(verification_token) diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py new file mode 100644 index 0000000..20dda2b --- /dev/null +++ b/backend/routers/club_join_requests.py @@ -0,0 +1,281 @@ +""" +Anträge auf Vereinsbeitritt: Nutzer stellt Antrag, Vereins-/Plattform-Admin nimmt an oder lehnt ab. +""" +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from auth import require_auth +from club_tenancy import can_manage_club_org +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api", tags=["club_join_requests"]) + +_ALLOWED_MEMBER_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"}) + + +def _normalize_roles(raw: List[str]) -> List[str]: + out: List[str] = [] + seen = set() + for r in raw: + if not isinstance(r, str): + raise HTTPException(status_code=400, detail="Rollen müssen Strings sein") + code = r.strip().lower() + if not code or code not in _ALLOWED_MEMBER_ROLES: + raise HTTPException(status_code=400, detail=f"Unbekannte Rolle: {code}") + if code not in seen: + seen.add(code) + out.append(code) + return out + + +def _club_active(cur, club_id: int) -> bool: + cur.execute("SELECT 1 FROM clubs WHERE id = %s AND status = 'active'", (club_id,)) + return cur.fetchone() is not None + + +def _assert_manage_club(cur, session: dict, club_id: int) -> None: + pid = session["profile_id"] + role = session.get("role") + if not can_manage_club_org(cur, pid, club_id, role): + raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein") + + +def _is_active_member(cur, profile_id: int, club_id: int) -> bool: + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND status = 'active' + LIMIT 1 + """, + (profile_id, club_id), + ) + return cur.fetchone() is not None + + +def _upsert_active_member_with_roles(cur, club_id: int, profile_id: int, roles: List[str]) -> None: + roles_n = _normalize_roles(roles) + if not roles_n: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + 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 + """, + (profile_id, club_id), + ) + cm_id = cur.fetchone()["id"] + cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) + for rc in roles_n: + 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), + ) + + +class JoinRequestCreate(BaseModel): + club_id: int = Field(..., ge=1) + message: Optional[str] = Field(None, max_length=2000) + + +class JoinRequestAccept(BaseModel): + roles: List[str] = Field(default_factory=lambda: ["trainer"]) + + +def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: + cur.execute( + """ + SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation + FROM club_membership_requests r + INNER JOIN clubs c ON c.id = r.club_id + WHERE r.id = %s AND r.profile_id = %s + """, + (req_id, viewer_profile_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + return r2d(row) + + +@router.get("/me/club-join-requests") +def get_my_join_requests(session: dict = Depends(require_auth)): + pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation + FROM club_membership_requests r + INNER JOIN clubs c ON c.id = r.club_id + WHERE r.profile_id = %s + ORDER BY r.created_at DESC + LIMIT 100 + """, + (pid,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/me/club-join-requests", status_code=201) +def create_my_join_request(body: JoinRequestCreate, session: dict = Depends(require_auth)): + """Antrag stellen (nicht möglich wenn bereits aktives Mitglied).""" + pid = session["profile_id"] + msg = (body.message or "").strip() or None + cid = body.club_id + + with get_db() as conn: + cur = get_cursor(conn) + if not _club_active(cur, cid): + raise HTTPException(status_code=404, detail="Verein nicht gefunden oder nicht aktiv") + + if _is_active_member(cur, pid, cid): + raise HTTPException(status_code=400, detail="Du bist bereits Mitglied in diesem Verein") + + cur.execute( + """ + SELECT id FROM club_membership_requests + WHERE profile_id = %s AND club_id = %s AND status = 'pending' + LIMIT 1 + """, + (pid, cid), + ) + if cur.fetchone(): + raise HTTPException(status_code=409, detail="Für diesen Verein liegt bereits ein offener Antrag vor") + + cur.execute( + """ + INSERT INTO club_membership_requests (profile_id, club_id, status, message) + VALUES (%s, %s, 'pending', %s) + RETURNING id + """, + (pid, cid, msg), + ) + rid = cur.fetchone()["id"] + conn.commit() + + with get_db() as conn: + cur = get_cursor(conn) + return _response_one(cur, rid, pid) + + +@router.delete("/me/club-join-requests/{request_id}") +def withdraw_my_join_request(request_id: int, session: dict = Depends(require_auth)): + pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'withdrawn', updated_at = NOW() + WHERE id = %s AND profile_id = %s AND status = 'pending' + RETURNING id + """, + (request_id, pid), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") + conn.commit() + return {"ok": True} + + +@router.get("/clubs/{club_id}/join-requests") +def list_club_join_requests(club_id: int, session: dict = Depends(require_auth)): + """Offene Anträge für einen Verein (Vereins-/Plattform-Admin).""" + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, session, club_id) + cur.execute( + """ + SELECT r.*, p.email AS applicant_email, p.name AS applicant_name + FROM club_membership_requests r + INNER JOIN profiles p ON p.id = r.profile_id + WHERE r.club_id = %s AND r.status = 'pending' + ORDER BY r.created_at ASC + """, + (club_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/clubs/{club_id}/join-requests/{request_id}/accept") +def accept_club_join_request( + club_id: int, + request_id: int, + body: JoinRequestAccept, + session: dict = Depends(require_auth), +): + admin_pid = session["profile_id"] + roles = _normalize_roles(body.roles) + + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, session, club_id) + + cur.execute( + """ + SELECT id, profile_id, status FROM club_membership_requests + WHERE id = %s AND club_id = %s + """, + (request_id, club_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + if row["status"] != "pending": + raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen") + + applicant_id = row["profile_id"] + + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'accepted', + decided_by_profile_id = %s, + decided_at = NOW(), + updated_at = NOW() + WHERE id = %s AND club_id = %s AND status = 'pending' + RETURNING id + """, + (admin_pid, request_id, club_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=409, detail="Antrag konnte nicht angenommen werden") + + _upsert_active_member_with_roles(cur, club_id, applicant_id, roles) + conn.commit() + + return {"ok": True, "profile_id": applicant_id, "club_id": club_id} + + +@router.post("/clubs/{club_id}/join-requests/{request_id}/reject") +def reject_club_join_request(club_id: int, request_id: int, session: dict = Depends(require_auth)): + admin_pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, session, club_id) + + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'rejected', + decided_by_profile_id = %s, + decided_at = NOW(), + updated_at = NOW() + WHERE id = %s AND club_id = %s AND status = 'pending' + RETURNING id + """, + (admin_pid, request_id, club_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") + conn.commit() + return {"ok": True} diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py index bcfb2ed..841a33d 100644 --- a/backend/routers/clubs.py +++ b/backend/routers/clubs.py @@ -57,6 +57,48 @@ def list_clubs( return [r2d(r) for r in rows] +# ── Öffentliches Vereinsverzeichnis (Registrierung / Antrag ohne Mitgliedschaft) ── +@router.get("/clubs/public-directory") +def public_club_directory(): + """Aktive Vereine zur Auswahl bei Registrierung oder Beitrittsantrag (nur id, name, Kürzel).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name, abbreviation + FROM clubs + WHERE status = 'active' + ORDER BY name + """ + ) + return [r2d(r) for r in cur.fetchall()] + + +# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ── +@router.get("/clubs/{club_id}/members/directory") +def club_members_directory(club_id: int, session=Depends(require_auth)): + 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) + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Verein nicht gefunden") + cur.execute( + """ + SELECT p.id, p.name, p.email + FROM club_members cm + INNER JOIN profiles p ON p.id = cm.profile_id + WHERE cm.club_id = %s AND cm.status = 'active' + ORDER BY COALESCE(p.name, ''), p.email + """, + (club_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + # ── Get Club ────────────────────────────────────────────────────────── @router.get("/clubs/{club_id}") def get_club(club_id: int, session=Depends(require_auth)): diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index ca78d09..035d42c 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -11,7 +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 club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin from models import ProfileCreate, ProfileUpdate router = APIRouter(prefix="/api", tags=["profiles"]) @@ -50,12 +50,20 @@ def get_current_profile(session=Depends(require_auth)): # ── Admin Profile Management ────────────────────────────────────────────────── @router.get("/profiles") def list_profiles(session=Depends(require_auth)): - """List all profiles (admin).""" + """Liste aller Profile (nur Plattform-Admin).""" + role = (session.get("role") or "").lower() + if not is_platform_admin(role): + raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren dürfen alle Profile einsehen") with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles ORDER BY created") rows = cur.fetchall() - return [r2d(r) for r in rows] + out = [] + for r in rows: + d = r2d(r) + d.pop("pin_hash", None) + out.append(d) + return out @router.post("/profiles") diff --git a/backend/version.py b/backend/version.py index 4a8a9d1..c075971 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,14 +1,15 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.17" +APP_VERSION = "0.8.18" BUILD_DATE = "2026-05-05" -DB_SCHEMA_VERSION = "20260505039" +DB_SCHEMA_VERSION = "20260505040" MODULE_VERSIONS = { - "auth": "1.0.0", - "profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id - "clubs": "0.3.0", + "auth": "1.1.0", # Registrierung: optional requested_club_id → Beitrittsantrag + "profiles": "1.2.0", # GET /profiles nur Plattform-Admin; pin_hash aus Liste entfernt + "clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints "club_memberships": "1.0.0", + "club_join_requests": "1.0.0", "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", @@ -24,6 +25,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.18", + "date": "2026-05-05", + "changes": [ + "DB 040 club_membership_requests; API Antrag/Abrufen/annehmen/ablehnen; öffentliches Vereinsverzeichnis; Mitglieder-Directory für Trainerwahl", + "GUI: Vereinsverwaltung Tab Mitglieder & Anträge; Registrierung/Einstellungen Vereinsantrag; Gruppenformular Haupt- und Co-Trainer", + "GET /profiles nur noch für Plattform-Admins", + ], + }, { "version": "0.8.17", "date": "2026-05-05", diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx index cba52d3..ea6634c 100644 --- a/frontend/src/pages/AccountSettingsPage.jsx +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -10,6 +10,12 @@ function AccountSettingsPage() { const [name, setName] = useState('') const [savingProfile, setSavingProfile] = useState(false) + const [publicClubsDir, setPublicClubsDir] = useState([]) + const [myJoinRequests, setMyJoinRequests] = useState([]) + const [joinClubId, setJoinClubId] = useState('') + const [joinMessage, setJoinMessage] = useState('') + const [joinBusy, setJoinBusy] = useState(false) + const [newPw1, setNewPw1] = useState('') const [newPw2, setNewPw2] = useState('') const [savingPw, setSavingPw] = useState(false) @@ -22,6 +28,32 @@ function AccountSettingsPage() { setName(typeof user?.name === 'string' ? user.name : '') }, [user]) + const refreshJoinRequests = () => { + api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {}) + } + + useEffect(() => { + if (!user?.id) return + api.listPublicClubsDirectory().then(setPublicClubsDir).catch(() => {}) + refreshJoinRequests() + }, [user?.id]) + + const memberClubIds = new Set((user?.clubs || []).map((c) => c.id)) + const pendingClubIds = new Set( + myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id) + ) + const joinClubChoices = publicClubsDir.filter( + (c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id) + ) + + const joinStatusLabel = (s) => + ({ + pending: 'ausstehend', + accepted: 'angenommen', + rejected: 'abgelehnt', + withdrawn: 'zurückgezogen', + })[s] || s + /** API: boolean true / Legacy: fehlt oder false → als „nicht verifiziert“ behandeln */ const emailExplicitlyVerified = user?.email_verified === true || @@ -214,9 +246,133 @@ function AccountSettingsPage() { {user?.tier || 'free'} + + Vereine + + {user?.clubs?.length ? ( + <> + {user.clubs.map((c) => ( +
+ {c.name} + {': '} + {(c.roles || []).length ? (c.roles || []).join(', ') : '—'} +
+ ))} + + ) : ( + '—' + )} +
+
+

Vereinsbeitritt

+

+ Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter + „Vereinsverwaltung → Mitglieder“ annehmen oder ablehnen. +

+ + {myJoinRequests.length > 0 && ( +
+ Meine Anträge +
    + {myJoinRequests.map((r) => ( +
  • + {r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)} + {r.status === 'pending' ? ( + <> + {' '} + + + ) : null} +
  • + ))} +
+
+ )} + +
{ + e.preventDefault() + if (!joinClubId) { + showErr('Bitte einen Verein auswählen.') + return + } + setJoinBusy(true) + try { + await api.createClubJoinRequest({ + club_id: parseInt(joinClubId, 10), + message: (joinMessage || '').trim() || undefined, + }) + setJoinMessage('') + setJoinClubId('') + refreshJoinRequests() + await checkAuth() + showOk('Antrag gesendet.') + } catch (err) { + showErr(err.message || 'Antrag fehlgeschlagen.') + } finally { + setJoinBusy(false) + } + }} + > + + + +