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)
|
# Zusätzlich für verified_pending_club (Verein bewerben)
|
||||||
PENDING_CLUB_PREFIXES = (
|
PENDING_CLUB_PREFIXES = (
|
||||||
"/api/me/club-join-requests",
|
"/api/me/club-join-requests",
|
||||||
|
"/api/me/club-creation-requests",
|
||||||
)
|
)
|
||||||
|
|
||||||
_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$")
|
_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$")
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# 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(auth.router)
|
||||||
app.include_router(profiles.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(clubs.router)
|
||||||
app.include_router(club_memberships.router)
|
app.include_router(club_memberships.router)
|
||||||
app.include_router(club_join_requests.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_users.router)
|
||||||
app.include_router(admin_user_content.router)
|
app.include_router(admin_user_content.router)
|
||||||
app.include_router(me_entitlements.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
|
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():
|
def test_active_member_domain_ok():
|
||||||
allowed, reason = check_api_onboarding_gate(
|
allowed, reason = check_api_onboarding_gate(
|
||||||
path="/api/exercises",
|
path="/api/exercises",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.190"
|
APP_VERSION = "0.8.191"
|
||||||
BUILD_DATE = "2026-05-23"
|
BUILD_DATE = "2026-06-06"
|
||||||
DB_SCHEMA_VERSION = "20260606079"
|
DB_SCHEMA_VERSION = "20260606080"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"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
|
"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_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||||
"club_join_requests": "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
|
"admin_users": "1.0.0", # GET /api/admin/users
|
||||||
"club_features": "1.2.0", # M4: club_features_map für /me/entitlements
|
"club_features": "1.2.0", # M4: club_features_map für /me/entitlements
|
||||||
"entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features
|
"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 TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage'))
|
||||||
const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
|
const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
|
||||||
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
|
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
|
||||||
|
const AdminClubCreationRequestsPage = lazy(() => import('./pages/AdminClubCreationRequestsPage'))
|
||||||
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
|
||||||
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
const LegalPage = lazy(() => import('./pages/LegalPage'))
|
||||||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||||
|
|
@ -282,6 +283,14 @@ const appRouter = createBrowserRouter([
|
||||||
</PlatformAdminRoute>
|
</PlatformAdminRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/club-creation-requests',
|
||||||
|
element: (
|
||||||
|
<PlatformAdminRoute>
|
||||||
|
<AdminClubCreationRequestsPage />
|
||||||
|
</PlatformAdminRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/hierarchy',
|
path: 'admin/hierarchy',
|
||||||
element: (
|
element: (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NavLink } from 'react-router-dom'
|
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).
|
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||||
|
|
@ -8,6 +8,7 @@ export default function AdminPageNav() {
|
||||||
const pages = [
|
const pages = [
|
||||||
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
{ 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/user-content', label: 'Nutzer-Inhalte', icon: Activity },
|
||||||
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
{ 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',
|
withdrawn: 'zurückgezogen',
|
||||||
})[s] || s
|
})[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).
|
* Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A).
|
||||||
*/
|
*/
|
||||||
|
|
@ -23,6 +31,12 @@ export default function OnboardingPage() {
|
||||||
const [joinClubId, setJoinClubId] = useState('')
|
const [joinClubId, setJoinClubId] = useState('')
|
||||||
const [joinMessage, setJoinMessage] = useState('')
|
const [joinMessage, setJoinMessage] = useState('')
|
||||||
const [joinBusy, setJoinBusy] = useState(false)
|
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 [error, setError] = useState('')
|
||||||
const [ok, setOk] = useState('')
|
const [ok, setOk] = useState('')
|
||||||
|
|
||||||
|
|
@ -34,9 +48,15 @@ export default function OnboardingPage() {
|
||||||
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
|
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshCreationRequests = () => {
|
||||||
|
if (!emailOk) return
|
||||||
|
api.getMyClubCreationRequests().then(setMyCreationRequests).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {})
|
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {})
|
||||||
refreshJoinRequests()
|
refreshJoinRequests()
|
||||||
|
refreshCreationRequests()
|
||||||
}, [user?.id, emailOk])
|
}, [user?.id, emailOk])
|
||||||
|
|
||||||
const memberClubIds = new Set((user?.clubs || []).map((c) => c.id))
|
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)
|
(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) => {
|
const handleJoin = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
@ -191,11 +245,126 @@ export default function OnboardingPage() {
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Neuen Verein gründen</h2>
|
<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 }}>
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||||
Die Beantragung einer neuen Vereinsgründung wird als Nächstes freigeschaltet (Freigabe durch
|
Stelle einen Antrag auf Vereinsgründung. Nach Freigabe durch den Plattform-Administrator
|
||||||
den Plattform-Administrator). Bis dahin wende dich an{' '}
|
wird der Verein mit Free-Abo angelegt und du wirst Hauptverwalter.
|
||||||
<a href="mailto:support@jinkendo.de">support@jinkendo.de</a> oder tritt einem bestehenden Verein bei.
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,35 @@ export async function withdrawClubJoinRequest(requestId) {
|
||||||
return request(`/api/me/club-join-requests/${requestId}`, { method: 'DELETE' })
|
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). */
|
/** Offene Anträge (Vereins-/Plattform-Admin). */
|
||||||
export async function listClubJoinRequests(clubId) {
|
export async function listClubJoinRequests(clubId) {
|
||||||
return request(`/api/clubs/${clubId}/join-requests`)
|
return request(`/api/clubs/${clubId}/join-requests`)
|
||||||
|
|
@ -889,6 +918,12 @@ export const api = {
|
||||||
getMyClubJoinRequests,
|
getMyClubJoinRequests,
|
||||||
createClubJoinRequest,
|
createClubJoinRequest,
|
||||||
withdrawClubJoinRequest,
|
withdrawClubJoinRequest,
|
||||||
|
getMyClubCreationRequests,
|
||||||
|
createClubCreationRequest,
|
||||||
|
withdrawClubCreationRequest,
|
||||||
|
listAdminClubCreationRequests,
|
||||||
|
approveClubCreationRequest,
|
||||||
|
rejectClubCreationRequest,
|
||||||
listClubJoinRequests,
|
listClubJoinRequests,
|
||||||
acceptClubJoinRequest,
|
acceptClubJoinRequest,
|
||||||
rejectClubJoinRequest,
|
rejectClubJoinRequest,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user