Phase 0a Mandantenfähigkeit
This commit is contained in:
parent
0748990328
commit
0c044249d9
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
213
.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
Normal file
213
.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
Normal file
|
|
@ -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
|
||||
127
backend/club_tenancy.py
Normal file
127
backend/club_tenancy.py
Normal file
|
|
@ -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
|
||||
|
|
@ -99,6 +99,8 @@ def health_ready():
|
|||
"skill_categories",
|
||||
"maturity_models",
|
||||
"sessions",
|
||||
"club_members",
|
||||
"club_member_roles",
|
||||
)
|
||||
tables: dict = {}
|
||||
err: Optional[str] = None
|
||||
|
|
|
|||
114
backend/migrations/039_club_membership_rbac.sql
Normal file
114
backend/migrations/039_club_membership_rbac.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</Link>
|
||||
|
||||
{(clubs?.length ?? 0) > 1 && (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.8125rem', color: 'var(--text2)' }}>
|
||||
<span>Verein</span>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ padding: '0.35rem 0.5rem', minWidth: '9rem', fontSize: '0.8125rem' }}
|
||||
value={selectClubId ?? ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (v) setActiveClub(Number(v))
|
||||
}}
|
||||
>
|
||||
{clubs.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* User Menu */}
|
||||
<div style={{
|
||||
borderLeft: '1px solid var(--border)',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,35 @@
|
|||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import api from '../utils/api'
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
function syncStoredActiveClub(profile) {
|
||||
const clubs = profile?.clubs || []
|
||||
const ids = new Set(clubs.map((c) => 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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</span>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{canManageClub(club.id) && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -243,6 +273,7 @@ function ClubsPage() {
|
|||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{isSuperAdmin && (
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
|
|
@ -254,6 +285,7 @@ function ClubsPage() {
|
|||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -269,7 +301,7 @@ function ClubsPage() {
|
|||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h2>Sparten</h2>
|
||||
{isAdmin && (
|
||||
{canManageOrgSomewhere && (
|
||||
<button className="btn btn-primary" onClick={() => handleCreate('division')}>
|
||||
+ Neue Sparte
|
||||
</button>
|
||||
|
|
@ -306,7 +338,7 @@ function ClubsPage() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{canManageClub(division.club_id) && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -340,7 +372,7 @@ function ClubsPage() {
|
|||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h2>Trainingsgruppen</h2>
|
||||
{canCreateClub && (
|
||||
{canCreateTrainingGroup && (
|
||||
<button className="btn btn-primary" onClick={() => handleCreate('group')}>
|
||||
+ Neue Gruppe
|
||||
</button>
|
||||
|
|
@ -377,7 +409,7 @@ function ClubsPage() {
|
|||
{group.age_group && <div>👶 {group.age_group}</div>}
|
||||
</div>
|
||||
|
||||
{(isAdmin || group.trainer_id === user?.id) && (
|
||||
{canEditGroup(group) && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -386,7 +418,7 @@ function ClubsPage() {
|
|||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{isAdmin && (
|
||||
{canDeleteGroup(group) && (
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
|
|
@ -485,6 +517,28 @@ function ClubsPage() {
|
|||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!editing && canCreateClub && (
|
||||
<div className="form-row">
|
||||
<label className="form-label">Hauptverwalter (Profil-ID) *</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="form-input"
|
||||
value={formData.primary_admin_profile_id ?? ''}
|
||||
onChange={(e) =>
|
||||
updateFormField(
|
||||
'primary_admin_profile_id',
|
||||
e.target.value === '' ? '' : parseInt(e.target.value, 10)
|
||||
)
|
||||
}
|
||||
required
|
||||
/>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
||||
Nur Plattform-Administratoren legen Vereine an. Standard ist deine eigene Profil-ID.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user