Phase 0a Mandantenfähigkeit
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 3m38s

This commit is contained in:
Lars 2026-05-05 16:12:17 +02:00
parent 0748990328
commit 0c044249d9
15 changed files with 982 additions and 193 deletions

View File

@ -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).

View 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, §1718 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004008)
---
## 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-004008: 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
View 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

View File

@ -99,6 +99,8 @@ def health_ready():
"skill_categories",
"maturity_models",
"sessions",
"club_members",
"club_member_roles",
)
tables: dict = {}
err: Optional[str] = None

View 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);

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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:

View File

@ -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",

View File

@ -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)',

View File

@ -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 (

View File

@ -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>
)}
</>
)}

View File

@ -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,

View File

@ -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",