From 0f08e8df5804e1c033e6de3e96d8fe9e4d1402d8 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 16:40:49 +0200 Subject: [PATCH] feat: enhance club management features and member requests - Updated the backend to include a new `requested_club_id` field in the registration request model. - Replaced the club memberships router with a new club join requests router for better management of membership applications. - Added API endpoints for listing public clubs and managing club join requests, improving user experience during registration and membership processes. - Enhanced the ClubsPage in the frontend to support member management and join requests, including new modals for adding members and handling requests. - Updated API utility functions to accommodate new endpoints for club join requests and public club listings. --- backend/main.py | 4 +- .../040_club_membership_requests.sql | 29 + backend/models.py | 1 + backend/routers/auth.py | 26 + backend/routers/club_join_requests.py | 281 ++++++++ backend/routers/clubs.py | 42 ++ backend/routers/profiles.py | 14 +- backend/version.py | 20 +- frontend/src/pages/AccountSettingsPage.jsx | 156 +++++ frontend/src/pages/ClubsPage.jsx | 662 +++++++++++++++++- frontend/src/pages/LoginPage.jsx | 38 +- frontend/src/utils/api.js | 57 +- frontend/src/version.js | 2 +- 13 files changed, 1313 insertions(+), 19 deletions(-) create mode 100644 backend/migrations/040_club_membership_requests.sql create mode 100644 backend/routers/club_join_requests.py diff --git a/backend/main.py b/backend/main.py index 27fffbb..c8c90b5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -154,14 +154,14 @@ def read_root(): } # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin app.include_router(auth.router) app.include_router(profiles.router) app.include_router(exercises.router) app.include_router(exercise_progression_graphs.router) app.include_router(clubs.router) -app.include_router(club_memberships.router) +app.include_router(club_join_requests.router) app.include_router(skills.router) app.include_router(training_planning.router) app.include_router(training_framework_programs.router) diff --git a/backend/migrations/040_club_membership_requests.sql b/backend/migrations/040_club_membership_requests.sql new file mode 100644 index 0000000..976981a --- /dev/null +++ b/backend/migrations/040_club_membership_requests.sql @@ -0,0 +1,29 @@ +-- Migration 040: Antrag auf Vereinsbeitritt (pending → accept/reject durch Vereins-/Plattform-Admin) + +CREATE TABLE IF NOT EXISTS club_membership_requests ( + id SERIAL PRIMARY KEY, + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')), + message TEXT, + decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + decided_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_club_membership_requests_pending + ON club_membership_requests (profile_id, club_id) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_club_membership_requests_club_status + ON club_membership_requests (club_id, status); + +CREATE INDEX IF NOT EXISTS idx_club_membership_requests_profile + ON club_membership_requests (profile_id); + +DROP TRIGGER IF EXISTS club_membership_requests_update ON club_membership_requests; +CREATE TRIGGER club_membership_requests_update + BEFORE UPDATE ON club_membership_requests + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); diff --git a/backend/models.py b/backend/models.py index f8c9cb9..ecae0d7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -19,6 +19,7 @@ class RegisterRequest(BaseModel): email: EmailStr password: str name: Optional[str] = None + requested_club_id: Optional[int] = Field(default=None, ge=1) class PasswordResetRequest(BaseModel): email: str diff --git a/backend/routers/auth.py b/backend/routers/auth.py index b740576..bdfa1e3 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -302,7 +302,33 @@ async def register(req: RegisterRequest, request: Request): email_verified, verification_token, verification_expires, trial_ends_at, created_at ) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) + RETURNING id """, (name, email, pin_hash, role, verification_token, verification_expires, trial_ends)) + new_profile_id = cur.fetchone()["id"] + + req_club = req.requested_club_id + if req_club is not None: + cur.execute( + "SELECT id FROM clubs WHERE id = %s AND status = 'active'", + (int(req_club),), + ) + if cur.fetchone(): + cur.execute( + """ + SELECT id FROM club_membership_requests + WHERE profile_id = %s AND club_id = %s AND status = 'pending' + LIMIT 1 + """, + (new_profile_id, int(req_club)), + ) + if not cur.fetchone(): + cur.execute( + """ + INSERT INTO club_membership_requests (profile_id, club_id, status, message) + VALUES (%s, %s, 'pending', NULL) + """, + (new_profile_id, int(req_club)), + ) verify_url = verification_link(verification_token) diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py new file mode 100644 index 0000000..20dda2b --- /dev/null +++ b/backend/routers/club_join_requests.py @@ -0,0 +1,281 @@ +""" +Anträge auf Vereinsbeitritt: Nutzer stellt Antrag, Vereins-/Plattform-Admin nimmt an oder lehnt ab. +""" +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from auth import require_auth +from club_tenancy import can_manage_club_org +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api", tags=["club_join_requests"]) + +_ALLOWED_MEMBER_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"}) + + +def _normalize_roles(raw: List[str]) -> List[str]: + out: List[str] = [] + seen = set() + for r in raw: + if not isinstance(r, str): + raise HTTPException(status_code=400, detail="Rollen müssen Strings sein") + code = r.strip().lower() + if not code or code not in _ALLOWED_MEMBER_ROLES: + raise HTTPException(status_code=400, detail=f"Unbekannte Rolle: {code}") + if code not in seen: + seen.add(code) + out.append(code) + return out + + +def _club_active(cur, club_id: int) -> bool: + cur.execute("SELECT 1 FROM clubs WHERE id = %s AND status = 'active'", (club_id,)) + return cur.fetchone() is not None + + +def _assert_manage_club(cur, session: dict, club_id: int) -> None: + pid = session["profile_id"] + role = session.get("role") + if not can_manage_club_org(cur, pid, club_id, role): + raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein") + + +def _is_active_member(cur, profile_id: int, club_id: int) -> bool: + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND club_id = %s AND status = 'active' + LIMIT 1 + """, + (profile_id, club_id), + ) + return cur.fetchone() is not None + + +def _upsert_active_member_with_roles(cur, club_id: int, profile_id: int, roles: List[str]) -> None: + roles_n = _normalize_roles(roles) + if not roles_n: + raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben") + cur.execute( + """ + INSERT INTO club_members (profile_id, club_id, status) + VALUES (%s, %s, 'active') + ON CONFLICT (profile_id, club_id) + DO UPDATE SET status = 'active', updated_at = NOW() + RETURNING id + """, + (profile_id, club_id), + ) + cm_id = cur.fetchone()["id"] + cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,)) + for rc in roles_n: + cur.execute( + """ + INSERT INTO club_member_roles (club_member_id, role_code) + VALUES (%s, %s) + ON CONFLICT (club_member_id, role_code) DO NOTHING + """, + (cm_id, rc), + ) + + +class JoinRequestCreate(BaseModel): + club_id: int = Field(..., ge=1) + message: Optional[str] = Field(None, max_length=2000) + + +class JoinRequestAccept(BaseModel): + roles: List[str] = Field(default_factory=lambda: ["trainer"]) + + +def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: + cur.execute( + """ + SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation + FROM club_membership_requests r + INNER JOIN clubs c ON c.id = r.club_id + WHERE r.id = %s AND r.profile_id = %s + """, + (req_id, viewer_profile_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + return r2d(row) + + +@router.get("/me/club-join-requests") +def get_my_join_requests(session: dict = Depends(require_auth)): + pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation + FROM club_membership_requests r + INNER JOIN clubs c ON c.id = r.club_id + WHERE r.profile_id = %s + ORDER BY r.created_at DESC + LIMIT 100 + """, + (pid,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/me/club-join-requests", status_code=201) +def create_my_join_request(body: JoinRequestCreate, session: dict = Depends(require_auth)): + """Antrag stellen (nicht möglich wenn bereits aktives Mitglied).""" + pid = session["profile_id"] + msg = (body.message or "").strip() or None + cid = body.club_id + + with get_db() as conn: + cur = get_cursor(conn) + if not _club_active(cur, cid): + raise HTTPException(status_code=404, detail="Verein nicht gefunden oder nicht aktiv") + + if _is_active_member(cur, pid, cid): + raise HTTPException(status_code=400, detail="Du bist bereits Mitglied in diesem Verein") + + cur.execute( + """ + SELECT id FROM club_membership_requests + WHERE profile_id = %s AND club_id = %s AND status = 'pending' + LIMIT 1 + """, + (pid, cid), + ) + if cur.fetchone(): + raise HTTPException(status_code=409, detail="Für diesen Verein liegt bereits ein offener Antrag vor") + + cur.execute( + """ + INSERT INTO club_membership_requests (profile_id, club_id, status, message) + VALUES (%s, %s, 'pending', %s) + RETURNING id + """, + (pid, cid, msg), + ) + rid = cur.fetchone()["id"] + conn.commit() + + with get_db() as conn: + cur = get_cursor(conn) + return _response_one(cur, rid, pid) + + +@router.delete("/me/club-join-requests/{request_id}") +def withdraw_my_join_request(request_id: int, session: dict = Depends(require_auth)): + pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'withdrawn', updated_at = NOW() + WHERE id = %s AND profile_id = %s AND status = 'pending' + RETURNING id + """, + (request_id, pid), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") + conn.commit() + return {"ok": True} + + +@router.get("/clubs/{club_id}/join-requests") +def list_club_join_requests(club_id: int, session: dict = Depends(require_auth)): + """Offene Anträge für einen Verein (Vereins-/Plattform-Admin).""" + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, session, club_id) + cur.execute( + """ + SELECT r.*, p.email AS applicant_email, p.name AS applicant_name + FROM club_membership_requests r + INNER JOIN profiles p ON p.id = r.profile_id + WHERE r.club_id = %s AND r.status = 'pending' + ORDER BY r.created_at ASC + """, + (club_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/clubs/{club_id}/join-requests/{request_id}/accept") +def accept_club_join_request( + club_id: int, + request_id: int, + body: JoinRequestAccept, + session: dict = Depends(require_auth), +): + admin_pid = session["profile_id"] + roles = _normalize_roles(body.roles) + + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, session, club_id) + + cur.execute( + """ + SELECT id, profile_id, status FROM club_membership_requests + WHERE id = %s AND club_id = %s + """, + (request_id, club_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + if row["status"] != "pending": + raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen") + + applicant_id = row["profile_id"] + + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'accepted', + decided_by_profile_id = %s, + decided_at = NOW(), + updated_at = NOW() + WHERE id = %s AND club_id = %s AND status = 'pending' + RETURNING id + """, + (admin_pid, request_id, club_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=409, detail="Antrag konnte nicht angenommen werden") + + _upsert_active_member_with_roles(cur, club_id, applicant_id, roles) + conn.commit() + + return {"ok": True, "profile_id": applicant_id, "club_id": club_id} + + +@router.post("/clubs/{club_id}/join-requests/{request_id}/reject") +def reject_club_join_request(club_id: int, request_id: int, session: dict = Depends(require_auth)): + admin_pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + _assert_manage_club(cur, session, club_id) + + cur.execute( + """ + UPDATE club_membership_requests + SET status = 'rejected', + decided_by_profile_id = %s, + decided_at = NOW(), + updated_at = NOW() + WHERE id = %s AND club_id = %s AND status = 'pending' + RETURNING id + """, + (admin_pid, request_id, club_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") + conn.commit() + return {"ok": True} diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py index bcfb2ed..841a33d 100644 --- a/backend/routers/clubs.py +++ b/backend/routers/clubs.py @@ -57,6 +57,48 @@ def list_clubs( return [r2d(r) for r in rows] +# ── Öffentliches Vereinsverzeichnis (Registrierung / Antrag ohne Mitgliedschaft) ── +@router.get("/clubs/public-directory") +def public_club_directory(): + """Aktive Vereine zur Auswahl bei Registrierung oder Beitrittsantrag (nur id, name, Kürzel).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name, abbreviation + FROM clubs + WHERE status = 'active' + ORDER BY name + """ + ) + return [r2d(r) for r in cur.fetchall()] + + +# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ── +@router.get("/clubs/{club_id}/members/directory") +def club_members_directory(club_id: int, session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: + cur = get_cursor(conn) + if not is_platform_admin(role): + assert_club_member(cur, profile_id, club_id) + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Verein nicht gefunden") + cur.execute( + """ + SELECT p.id, p.name, p.email + FROM club_members cm + INNER JOIN profiles p ON p.id = cm.profile_id + WHERE cm.club_id = %s AND cm.status = 'active' + ORDER BY COALESCE(p.name, ''), p.email + """, + (club_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + # ── Get Club ────────────────────────────────────────────────────────── @router.get("/clubs/{club_id}") def get_club(club_id: int, session=Depends(require_auth)): diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index ca78d09..035d42c 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d from auth import require_auth -from club_tenancy import assert_club_member, memberships_with_roles +from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin from models import ProfileCreate, ProfileUpdate router = APIRouter(prefix="/api", tags=["profiles"]) @@ -50,12 +50,20 @@ def get_current_profile(session=Depends(require_auth)): # ── Admin Profile Management ────────────────────────────────────────────────── @router.get("/profiles") def list_profiles(session=Depends(require_auth)): - """List all profiles (admin).""" + """Liste aller Profile (nur Plattform-Admin).""" + role = (session.get("role") or "").lower() + if not is_platform_admin(role): + raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren dürfen alle Profile einsehen") with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles ORDER BY created") rows = cur.fetchall() - return [r2d(r) for r in rows] + out = [] + for r in rows: + d = r2d(r) + d.pop("pin_hash", None) + out.append(d) + return out @router.post("/profiles") diff --git a/backend/version.py b/backend/version.py index 4a8a9d1..c075971 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,14 +1,15 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.17" +APP_VERSION = "0.8.18" BUILD_DATE = "2026-05-05" -DB_SCHEMA_VERSION = "20260505039" +DB_SCHEMA_VERSION = "20260505040" MODULE_VERSIONS = { - "auth": "1.0.0", - "profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id - "clubs": "0.3.0", + "auth": "1.1.0", # Registrierung: optional requested_club_id → Beitrittsantrag + "profiles": "1.2.0", # GET /profiles nur Plattform-Admin; pin_hash aus Liste entfernt + "clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints "club_memberships": "1.0.0", + "club_join_requests": "1.0.0", "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", @@ -24,6 +25,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.18", + "date": "2026-05-05", + "changes": [ + "DB 040 club_membership_requests; API Antrag/Abrufen/annehmen/ablehnen; öffentliches Vereinsverzeichnis; Mitglieder-Directory für Trainerwahl", + "GUI: Vereinsverwaltung Tab Mitglieder & Anträge; Registrierung/Einstellungen Vereinsantrag; Gruppenformular Haupt- und Co-Trainer", + "GET /profiles nur noch für Plattform-Admins", + ], + }, { "version": "0.8.17", "date": "2026-05-05", diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx index cba52d3..ea6634c 100644 --- a/frontend/src/pages/AccountSettingsPage.jsx +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -10,6 +10,12 @@ function AccountSettingsPage() { const [name, setName] = useState('') const [savingProfile, setSavingProfile] = useState(false) + const [publicClubsDir, setPublicClubsDir] = useState([]) + const [myJoinRequests, setMyJoinRequests] = useState([]) + const [joinClubId, setJoinClubId] = useState('') + const [joinMessage, setJoinMessage] = useState('') + const [joinBusy, setJoinBusy] = useState(false) + const [newPw1, setNewPw1] = useState('') const [newPw2, setNewPw2] = useState('') const [savingPw, setSavingPw] = useState(false) @@ -22,6 +28,32 @@ function AccountSettingsPage() { setName(typeof user?.name === 'string' ? user.name : '') }, [user]) + const refreshJoinRequests = () => { + api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {}) + } + + useEffect(() => { + if (!user?.id) return + api.listPublicClubsDirectory().then(setPublicClubsDir).catch(() => {}) + refreshJoinRequests() + }, [user?.id]) + + const memberClubIds = new Set((user?.clubs || []).map((c) => c.id)) + const pendingClubIds = new Set( + myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id) + ) + const joinClubChoices = publicClubsDir.filter( + (c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id) + ) + + const joinStatusLabel = (s) => + ({ + pending: 'ausstehend', + accepted: 'angenommen', + rejected: 'abgelehnt', + withdrawn: 'zurückgezogen', + })[s] || s + /** API: boolean true / Legacy: fehlt oder false → als „nicht verifiziert“ behandeln */ const emailExplicitlyVerified = user?.email_verified === true || @@ -214,9 +246,133 @@ function AccountSettingsPage() { {user?.tier || 'free'} + + Vereine + + {user?.clubs?.length ? ( + <> + {user.clubs.map((c) => ( +
+ {c.name} + {': '} + {(c.roles || []).length ? (c.roles || []).join(', ') : '—'} +
+ ))} + + ) : ( + '—' + )} +
+
+

Vereinsbeitritt

+

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

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