Add Club Creation Request Management Features
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Failing after 1m14s
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Failing after 1m14s
- Introduced endpoints for managing club creation requests, including fetching, creating, and withdrawing requests. - Updated the onboarding page to allow users to submit new club creation requests and view their existing requests. - Enhanced the admin interface with navigation and routing for club creation requests management. - Incremented version to 0.8.191 to reflect these new features and updates in the application.
This commit is contained in:
parent
8718cf5c70
commit
8ee8f52e0f
|
|
@ -40,6 +40,7 @@ AUTH_INFRA_PREFIXES = (
|
|||
# Zusätzlich für verified_pending_club (Verein bewerben)
|
||||
PENDING_CLUB_PREFIXES = (
|
||||
"/api/me/club-join-requests",
|
||||
"/api/me/club-creation-requests",
|
||||
)
|
||||
|
||||
_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$")
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -230,6 +230,7 @@ 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(club_creation_requests.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_user_content.router)
|
||||
app.include_router(me_entitlements.router)
|
||||
|
|
|
|||
41
backend/migrations/080_club_creation_requests.sql
Normal file
41
backend/migrations/080_club_creation_requests.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-- Migration 080: Antrag auf Vereinsgründung (M7)
|
||||
-- Nutzer verified_pending_club stellt Antrag; Plattform-Admin legt Verein + Abo an.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_creation_requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
proposed_name VARCHAR(200) NOT NULL,
|
||||
proposed_abbreviation VARCHAR(50),
|
||||
proposed_description TEXT,
|
||||
message TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')),
|
||||
decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
decided_at TIMESTAMP,
|
||||
created_club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_creation_requests_pending
|
||||
ON club_creation_requests (profile_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_creation_requests_status
|
||||
ON club_creation_requests (status, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_creation_requests_profile
|
||||
ON club_creation_requests (profile_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS club_creation_requests_update ON club_creation_requests;
|
||||
CREATE TRIGGER club_creation_requests_update
|
||||
BEFORE UPDATE ON club_creation_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
-- Capabilities (CAPABILITY_CATALOG.v1.md — club.creation_request.*)
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
|
||||
VALUES
|
||||
('club.creation_request.create', 'Vereinsgründung beantragen', 'club', 'verified_pending_club', NULL),
|
||||
('club.creation_request.read_own', 'Eigene Gründungsanträge', 'club', 'verified_pending_club', NULL),
|
||||
('club.creation_request.withdraw', 'Gründungsantrag zurückziehen', 'club', 'verified_pending_club', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
390
backend/routers/club_creation_requests.py
Normal file
390
backend/routers/club_creation_requests.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
"""
|
||||
Anträge auf Vereinsgründung: Nutzer stellt Antrag, Plattform-Admin legt Verein + Abo an.
|
||||
"""
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_tenancy import is_platform_admin
|
||||
from db import get_db, get_cursor, r2d
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["club_creation_requests"])
|
||||
|
||||
_FREE_PLAN_ID = "free"
|
||||
|
||||
|
||||
def _has_active_membership(cur, profile_id: int) -> bool:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM club_members
|
||||
WHERE profile_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _club_name_taken(cur, name: str, *, exclude_club_id: Optional[int] = None) -> bool:
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return False
|
||||
if exclude_club_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM clubs
|
||||
WHERE lower(trim(name)) = lower(trim(%s)) AND id <> %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(n, exclude_club_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM clubs
|
||||
WHERE lower(trim(name)) = lower(trim(%s))
|
||||
LIMIT 1
|
||||
""",
|
||||
(n,),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _provision_club_for_founder(
|
||||
cur,
|
||||
*,
|
||||
founder_profile_id: int,
|
||||
name: str,
|
||||
abbreviation: Optional[str],
|
||||
description: Optional[str],
|
||||
) -> int:
|
||||
"""Legt Verein, Mitgliedschaft (club_admin+trainer) und Free-Abo an."""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO clubs (name, abbreviation, description, status, primary_admin_profile_id)
|
||||
VALUES (%s, %s, %s, 'active', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(name, abbreviation, description, founder_profile_id),
|
||||
)
|
||||
club_id = int(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
|
||||
""",
|
||||
(founder_profile_id, 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),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_subscriptions (club_id, plan_id, status)
|
||||
VALUES (%s, %s, 'active')
|
||||
ON CONFLICT (club_id) DO NOTHING
|
||||
""",
|
||||
(club_id, _FREE_PLAN_ID),
|
||||
)
|
||||
return club_id
|
||||
|
||||
|
||||
class CreationRequestCreate(BaseModel):
|
||||
proposed_name: str = Field(..., min_length=2, max_length=200)
|
||||
proposed_abbreviation: Optional[str] = Field(None, max_length=50)
|
||||
proposed_description: Optional[str] = Field(None, max_length=5000)
|
||||
message: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS created_club_name
|
||||
FROM club_creation_requests r
|
||||
LEFT JOIN clubs c ON c.id = r.created_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)
|
||||
|
||||
|
||||
def _assert_platform_admin(tenant: TenantContext) -> None:
|
||||
if not is_platform_admin(tenant.global_role):
|
||||
raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren")
|
||||
|
||||
|
||||
@router.get("/me/club-creation-requests")
|
||||
def get_my_creation_requests(tenant: TenantContext = Depends(get_tenant_context)):
|
||||
assert_min_account_state(tenant, "verified_pending_club", endpoint="GET /me/club-creation-requests")
|
||||
pid = tenant.profile_id
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"club.creation_request.read_own",
|
||||
action="read",
|
||||
endpoint="GET /me/club-creation-requests",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*, c.name AS created_club_name
|
||||
FROM club_creation_requests r
|
||||
LEFT JOIN clubs c ON c.id = r.created_club_id
|
||||
WHERE r.profile_id = %s
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 50
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/me/club-creation-requests", status_code=201)
|
||||
def create_my_creation_request(
|
||||
body: CreationRequestCreate,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
assert_min_account_state(tenant, "verified_pending_club", endpoint="POST /me/club-creation-requests")
|
||||
pid = tenant.profile_id
|
||||
name = body.proposed_name.strip()
|
||||
abbr = (body.proposed_abbreviation or "").strip() or None
|
||||
desc = (body.proposed_description or "").strip() or None
|
||||
msg = (body.message or "").strip() or None
|
||||
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"club.creation_request.create",
|
||||
action="create",
|
||||
endpoint="POST /me/club-creation-requests",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
|
||||
if _has_active_membership(cur, pid):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Du bist bereits Vereinsmitglied — Gründungsantrag nicht möglich",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM club_creation_requests
|
||||
WHERE profile_id = %s AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="Es liegt bereits ein offener Gründungsantrag vor")
|
||||
|
||||
if _club_name_taken(cur, name):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Ein Verein mit diesem Namen existiert bereits — bitte anderen Namen wählen",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_creation_requests (
|
||||
profile_id, proposed_name, proposed_abbreviation,
|
||||
proposed_description, message, status
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, 'pending')
|
||||
RETURNING id
|
||||
""",
|
||||
(pid, name, abbr, desc, 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-creation-requests/{request_id}")
|
||||
def withdraw_my_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
assert_min_account_state(
|
||||
tenant, "verified_pending_club", endpoint="DELETE /me/club-creation-requests/{id}"
|
||||
)
|
||||
pid = tenant.profile_id
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"club.creation_request.withdraw",
|
||||
action="withdraw",
|
||||
endpoint="DELETE /me/club-creation-requests/{id}",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_creation_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("/admin/club-creation-requests")
|
||||
def list_admin_creation_requests(tenant: TenantContext = Depends(get_tenant_context)):
|
||||
_assert_platform_admin(tenant)
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"platform.club_creation.approve",
|
||||
action="list",
|
||||
endpoint="GET /admin/club-creation-requests",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.*,
|
||||
p.name AS applicant_name,
|
||||
p.email AS applicant_email,
|
||||
c.name AS created_club_name
|
||||
FROM club_creation_requests r
|
||||
INNER JOIN profiles p ON p.id = r.profile_id
|
||||
LEFT JOIN clubs c ON c.id = r.created_club_id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.created_at ASC
|
||||
"""
|
||||
)
|
||||
return [r2d(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/admin/club-creation-requests/{request_id}/approve")
|
||||
def approve_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
_assert_platform_admin(tenant)
|
||||
admin_pid = tenant.profile_id
|
||||
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"platform.club_creation.approve",
|
||||
action="approve",
|
||||
endpoint="POST /admin/club-creation-requests/{id}/approve",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, profile_id, proposed_name, proposed_abbreviation,
|
||||
proposed_description, status
|
||||
FROM club_creation_requests
|
||||
WHERE id = %s
|
||||
""",
|
||||
(request_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 = int(row["profile_id"])
|
||||
name = (row["proposed_name"] or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Vorgeschlagener Vereinsname fehlt")
|
||||
|
||||
if _has_active_membership(cur, applicant_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Antragsteller ist bereits Vereinsmitglied — Freigabe nicht möglich",
|
||||
)
|
||||
|
||||
if _club_name_taken(cur, name):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Ein Verein mit diesem Namen existiert bereits",
|
||||
)
|
||||
|
||||
club_id = _provision_club_for_founder(
|
||||
cur,
|
||||
founder_profile_id=applicant_id,
|
||||
name=name,
|
||||
abbreviation=row.get("proposed_abbreviation"),
|
||||
description=row.get("proposed_description"),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_creation_requests
|
||||
SET status = 'approved',
|
||||
decided_by_profile_id = %s,
|
||||
decided_at = NOW(),
|
||||
created_club_id = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'pending'
|
||||
RETURNING id
|
||||
""",
|
||||
(admin_pid, club_id, request_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="Antrag konnte nicht freigegeben werden")
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True, "club_id": club_id, "profile_id": applicant_id}
|
||||
|
||||
|
||||
@router.post("/admin/club-creation-requests/{request_id}/reject")
|
||||
def reject_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
_assert_platform_admin(tenant)
|
||||
admin_pid = tenant.profile_id
|
||||
|
||||
with get_db() as conn:
|
||||
probe_capability(
|
||||
tenant,
|
||||
"platform.club_creation.approve",
|
||||
action="reject",
|
||||
endpoint="POST /admin/club-creation-requests/{id}/reject",
|
||||
conn=conn,
|
||||
)
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE club_creation_requests
|
||||
SET status = 'rejected',
|
||||
decided_by_profile_id = %s,
|
||||
decided_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'pending'
|
||||
RETURNING id
|
||||
""",
|
||||
(admin_pid, request_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
@ -33,6 +33,16 @@ def test_join_request_allowed_for_pending():
|
|||
assert allowed
|
||||
|
||||
|
||||
def test_creation_request_allowed_for_pending():
|
||||
allowed, _ = check_api_onboarding_gate(
|
||||
path="/api/me/club-creation-requests",
|
||||
method="POST",
|
||||
profile_id=1,
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
assert allowed
|
||||
|
||||
|
||||
def test_active_member_domain_ok():
|
||||
allowed, reason = check_api_onboarding_gate(
|
||||
path="/api/exercises",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.190"
|
||||
BUILD_DATE = "2026-05-23"
|
||||
DB_SCHEMA_VERSION = "20260606079"
|
||||
APP_VERSION = "0.8.191"
|
||||
BUILD_DATE = "2026-06-06"
|
||||
DB_SCHEMA_VERSION = "20260606080"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -14,6 +14,7 @@ MODULE_VERSIONS = {
|
|||
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
|
||||
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_creation_requests": "1.0.0", # M7: Gründungsanträge + Admin-Freigabe
|
||||
"admin_users": "1.0.0", # GET /api/admin/users
|
||||
"club_features": "1.2.0", # M4: club_features_map für /me/entitlements
|
||||
"entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const AdminMaturityModelsPage = lazy(() => import('./pages/AdminMaturityModelsPa
|
|||
const TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage'))
|
||||
const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
|
||||
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
|
||||
const AdminClubCreationRequestsPage = lazy(() => import('./pages/AdminClubCreationRequestsPage'))
|
||||
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
||||
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||
|
|
@ -282,6 +283,14 @@ const appRouter = createBrowserRouter([
|
|||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/club-creation-requests',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminClubCreationRequestsPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/hierarchy',
|
||||
element: (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity, Building2 } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||
|
|
@ -8,6 +8,7 @@ export default function AdminPageNav() {
|
|||
const pages = [
|
||||
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
||||
{ to: '/admin/club-creation-requests', label: 'Vereinsgründungen', icon: Building2 },
|
||||
{ to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity },
|
||||
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||
|
|
|
|||
169
frontend/src/pages/AdminClubCreationRequestsPage.jsx
Normal file
169
frontend/src/pages/AdminClubCreationRequestsPage.jsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
return new Date(value).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Superadmin: offene Anträge auf Vereinsgründung freigeben oder ablehnen.
|
||||
*/
|
||||
export default function AdminClubCreationRequestsPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [requests, setRequests] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const rows = await api.listAdminClubCreationRequests()
|
||||
setRequests(Array.isArray(rows) ? rows : [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, load])
|
||||
|
||||
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||
|
||||
const handleApprove = async (id) => {
|
||||
if (!confirm('Verein anlegen und Antragsteller als Hauptverwalter eintragen?')) return
|
||||
setBusyId(id)
|
||||
setError('')
|
||||
try {
|
||||
await api.approveClubCreationRequest(id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (id) => {
|
||||
if (!confirm('Gründungsantrag wirklich ablehnen?')) return
|
||||
setBusyId(id)
|
||||
setError('')
|
||||
try {
|
||||
await api.rejectClubCreationRequest(id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-padding app-page">
|
||||
<AdminPageNav />
|
||||
<h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Vereinsgründungen</h1>
|
||||
<p style={{ color: 'var(--text2)', maxWidth: '42rem', lineHeight: 1.5 }}>
|
||||
Offene Anträge von verifizierten Nutzern ohne Vereinsmitgliedschaft. Bei Freigabe wird ein
|
||||
neuer Verein mit Free-Abo angelegt; der Antragsteller wird Vereinsadmin und Trainer.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" style={{ color: 'var(--danger)' }}>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="spinner" style={{ marginTop: '1rem' }}>
|
||||
Laden…
|
||||
</p>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="card" style={{ marginTop: '1rem' }}>
|
||||
<p style={{ margin: 0, color: 'var(--text2)' }}>Keine offenen Gründungsanträge.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
{requests.map((r) => (
|
||||
<div key={r.id} className="card">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem 1rem', marginBottom: '0.5rem' }}>
|
||||
<strong>{r.proposed_name}</strong>
|
||||
{r.proposed_abbreviation ? (
|
||||
<span style={{ color: 'var(--text2)' }}>({r.proposed_abbreviation})</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p style={{ margin: '0 0 0.35rem', fontSize: '0.9rem', color: 'var(--text2)' }}>
|
||||
Antragsteller: {r.applicant_name || '—'}{' '}
|
||||
{r.applicant_email ? `· ${r.applicant_email}` : ''}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 0.35rem', fontSize: '0.85rem', color: 'var(--text3)' }}>
|
||||
Eingereicht: {formatDate(r.created_at)}
|
||||
</p>
|
||||
{r.proposed_description ? (
|
||||
<p style={{ margin: '0.5rem 0', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>
|
||||
{r.proposed_description}
|
||||
</p>
|
||||
) : null}
|
||||
{r.message ? (
|
||||
<p
|
||||
style={{
|
||||
margin: '0.5rem 0 0',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--text2)',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
Nachricht: {r.message}
|
||||
</p>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.85rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busyId === r.id}
|
||||
onClick={() => handleApprove(r.id)}
|
||||
>
|
||||
{busyId === r.id ? '…' : 'Freigeben'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busyId === r.id}
|
||||
onClick={() => handleReject(r.id)}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,6 +13,14 @@ const joinStatusLabel = (s) =>
|
|||
withdrawn: 'zurückgezogen',
|
||||
})[s] || s
|
||||
|
||||
const creationStatusLabel = (s) =>
|
||||
({
|
||||
pending: 'ausstehend',
|
||||
approved: 'freigegeben',
|
||||
rejected: 'abgelehnt',
|
||||
withdrawn: 'zurückgezogen',
|
||||
})[s] || s
|
||||
|
||||
/**
|
||||
* Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A).
|
||||
*/
|
||||
|
|
@ -23,6 +31,12 @@ export default function OnboardingPage() {
|
|||
const [joinClubId, setJoinClubId] = useState('')
|
||||
const [joinMessage, setJoinMessage] = useState('')
|
||||
const [joinBusy, setJoinBusy] = useState(false)
|
||||
const [myCreationRequests, setMyCreationRequests] = useState([])
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createAbbr, setCreateAbbr] = useState('')
|
||||
const [createDesc, setCreateDesc] = useState('')
|
||||
const [createMessage, setCreateMessage] = useState('')
|
||||
const [createBusy, setCreateBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [ok, setOk] = useState('')
|
||||
|
||||
|
|
@ -34,9 +48,15 @@ export default function OnboardingPage() {
|
|||
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
|
||||
}
|
||||
|
||||
const refreshCreationRequests = () => {
|
||||
if (!emailOk) return
|
||||
api.getMyClubCreationRequests().then(setMyCreationRequests).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {})
|
||||
refreshJoinRequests()
|
||||
refreshCreationRequests()
|
||||
}, [user?.id, emailOk])
|
||||
|
||||
const memberClubIds = new Set((user?.clubs || []).map((c) => c.id))
|
||||
|
|
@ -47,6 +67,40 @@ export default function OnboardingPage() {
|
|||
(c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
|
||||
)
|
||||
|
||||
const hasPendingCreation = myCreationRequests.some((r) => r.status === 'pending')
|
||||
|
||||
const handleCreateClub = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setOk('')
|
||||
const name = (createName || '').trim()
|
||||
if (!name) {
|
||||
setError('Bitte einen Vereinsnamen angeben.')
|
||||
return
|
||||
}
|
||||
setCreateBusy(true)
|
||||
try {
|
||||
await api.createClubCreationRequest({
|
||||
proposed_name: name,
|
||||
proposed_abbreviation: (createAbbr || '').trim() || undefined,
|
||||
proposed_description: (createDesc || '').trim() || undefined,
|
||||
message: (createMessage || '').trim() || undefined,
|
||||
})
|
||||
setCreateName('')
|
||||
setCreateAbbr('')
|
||||
setCreateDesc('')
|
||||
setCreateMessage('')
|
||||
refreshCreationRequests()
|
||||
setOk(
|
||||
'Gründungsantrag gesendet. Nach Freigabe durch den Plattform-Administrator wird dein Verein angelegt.'
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Antrag fehlgeschlagen.')
|
||||
} finally {
|
||||
setCreateBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoin = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
|
@ -191,11 +245,126 @@ export default function OnboardingPage() {
|
|||
|
||||
<div className="card">
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Neuen Verein gründen</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: 0, lineHeight: 1.5 }}>
|
||||
Die Beantragung einer neuen Vereinsgründung wird als Nächstes freigeschaltet (Freigabe durch
|
||||
den Plattform-Administrator). Bis dahin wende dich an{' '}
|
||||
<a href="mailto:support@jinkendo.de">support@jinkendo.de</a> oder tritt einem bestehenden Verein bei.
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
Stelle einen Antrag auf Vereinsgründung. Nach Freigabe durch den Plattform-Administrator
|
||||
wird der Verein mit Free-Abo angelegt und du wirst Hauptverwalter.
|
||||
</p>
|
||||
|
||||
{myCreationRequests.length > 0 ? (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Meine Gründungsanträge</strong>
|
||||
<ul
|
||||
style={{
|
||||
margin: '0.5rem 0 0',
|
||||
paddingLeft: '1.25rem',
|
||||
color: 'var(--text2)',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{myCreationRequests.map((r) => (
|
||||
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
|
||||
{r.proposed_name} — {creationStatusLabel(r.status)}
|
||||
{r.status === 'approved' && r.created_club_name
|
||||
? ` (${r.created_club_name})`
|
||||
: null}
|
||||
{r.status === 'pending' ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
|
||||
onClick={async () => {
|
||||
if (!confirm('Antrag wirklich zurückziehen?')) return
|
||||
try {
|
||||
await api.withdrawClubCreationRequest(r.id)
|
||||
refreshCreationRequests()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Zurückziehen fehlgeschlagen.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
zurückziehen
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{r.status === 'approved' ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
|
||||
onClick={() => checkAuth()}
|
||||
>
|
||||
App aktualisieren
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasPendingCreation ? (
|
||||
<p style={{ margin: 0, color: 'var(--text2)', fontSize: '0.875rem' }}>
|
||||
Du hast bereits einen offenen Gründungsantrag. Bitte warte auf die Freigabe oder ziehe
|
||||
den Antrag zurück.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleCreateClub}>
|
||||
<label className="form-label" htmlFor="onb-create-name">
|
||||
Vereinsname
|
||||
</label>
|
||||
<input
|
||||
id="onb-create-name"
|
||||
className="form-input"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
maxLength={200}
|
||||
required
|
||||
/>
|
||||
<label className="form-label" htmlFor="onb-create-abbr" style={{ marginTop: '0.75rem' }}>
|
||||
Kürzel (optional)
|
||||
</label>
|
||||
<input
|
||||
id="onb-create-abbr"
|
||||
className="form-input"
|
||||
value={createAbbr}
|
||||
onChange={(e) => setCreateAbbr(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<label className="form-label" htmlFor="onb-create-desc" style={{ marginTop: '0.75rem' }}>
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="onb-create-desc"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={createDesc}
|
||||
onChange={(e) => setCreateDesc(e.target.value)}
|
||||
/>
|
||||
<label className="form-label" htmlFor="onb-create-msg" style={{ marginTop: '0.75rem' }}>
|
||||
Nachricht an den Administrator (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="onb-create-msg"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={createMessage}
|
||||
onChange={(e) => setCreateMessage(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={createBusy}
|
||||
style={{ marginTop: '0.85rem' }}
|
||||
>
|
||||
{createBusy ? 'Senden…' : 'Gründung beantragen'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -232,6 +232,35 @@ export async function withdrawClubJoinRequest(requestId) {
|
|||
return request(`/api/me/club-join-requests/${requestId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Eigene Anträge auf Vereinsgründung. */
|
||||
export async function getMyClubCreationRequests() {
|
||||
return request('/api/me/club-creation-requests')
|
||||
}
|
||||
|
||||
export async function createClubCreationRequest(payload) {
|
||||
return request('/api/me/club-creation-requests', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function withdrawClubCreationRequest(requestId) {
|
||||
return request(`/api/me/club-creation-requests/${requestId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Offene Gründungsanträge — Plattform-Admin. */
|
||||
export async function listAdminClubCreationRequests() {
|
||||
return request('/api/admin/club-creation-requests')
|
||||
}
|
||||
|
||||
export async function approveClubCreationRequest(requestId) {
|
||||
return request(`/api/admin/club-creation-requests/${requestId}/approve`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function rejectClubCreationRequest(requestId) {
|
||||
return request(`/api/admin/club-creation-requests/${requestId}/reject`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Offene Anträge (Vereins-/Plattform-Admin). */
|
||||
export async function listClubJoinRequests(clubId) {
|
||||
return request(`/api/clubs/${clubId}/join-requests`)
|
||||
|
|
@ -889,6 +918,12 @@ export const api = {
|
|||
getMyClubJoinRequests,
|
||||
createClubJoinRequest,
|
||||
withdrawClubJoinRequest,
|
||||
getMyClubCreationRequests,
|
||||
createClubCreationRequest,
|
||||
withdrawClubCreationRequest,
|
||||
listAdminClubCreationRequests,
|
||||
approveClubCreationRequest,
|
||||
rejectClubCreationRequest,
|
||||
listClubJoinRequests,
|
||||
acceptClubJoinRequest,
|
||||
rejectClubJoinRequest,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user