diff --git a/backend/account_onboarding_gate.py b/backend/account_onboarding_gate.py
index a3207cd..b643b10 100644
--- a/backend/account_onboarding_gate.py
+++ b/backend/account_onboarding_gate.py
@@ -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+)$")
diff --git a/backend/main.py b/backend/main.py
index 331c067..5d2ba17 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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)
diff --git a/backend/migrations/080_club_creation_requests.sql b/backend/migrations/080_club_creation_requests.sql
new file mode 100644
index 0000000..7b68baf
--- /dev/null
+++ b/backend/migrations/080_club_creation_requests.sql
@@ -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;
diff --git a/backend/routers/club_creation_requests.py b/backend/routers/club_creation_requests.py
new file mode 100644
index 0000000..fb09e03
--- /dev/null
+++ b/backend/routers/club_creation_requests.py
@@ -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}
diff --git a/backend/tests/test_account_onboarding_gate.py b/backend/tests/test_account_onboarding_gate.py
index e5c2aa6..0eee794 100644
--- a/backend/tests/test_account_onboarding_gate.py
+++ b/backend/tests/test_account_onboarding_gate.py
@@ -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",
diff --git a/backend/version.py b/backend/version.py
index e9d351d..d74872c 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -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
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f1feb8c..a0e100d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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([
),
},
+ {
+ path: 'admin/club-creation-requests',
+ element: (
+
+ Offene Anträge von verifizierten Nutzern ohne Vereinsmitgliedschaft. Bei Freigabe wird ein + neuer Verein mit Free-Abo angelegt; der Antragsteller wird Vereinsadmin und Trainer. +
+ + {error ? ( ++ {error} +
+ ) : null} + + {loading ? ( ++ Laden… +
+ ) : requests.length === 0 ? ( +Keine offenen Gründungsanträge.
++ Antragsteller: {r.applicant_name || '—'}{' '} + {r.applicant_email ? `· ${r.applicant_email}` : ''} +
++ Eingereicht: {formatDate(r.created_at)} +
+ {r.proposed_description ? ( ++ {r.proposed_description} +
+ ) : null} + {r.message ? ( ++ Nachricht: {r.message} +
+ ) : null} +- Die Beantragung einer neuen Vereinsgründung wird als Nächstes freigeschaltet (Freigabe durch - den Plattform-Administrator). Bis dahin wende dich an{' '} - support@jinkendo.de oder tritt einem bestehenden Verein bei. +
+ Stelle einen Antrag auf Vereinsgründung. Nach Freigabe durch den Plattform-Administrator + wird der Verein mit Free-Abo angelegt und du wirst Hauptverwalter.
+ + {myCreationRequests.length > 0 ? ( ++ Du hast bereits einen offenen Gründungsantrag. Bitte warte auf die Freigabe oder ziehe + den Antrag zurück. +
+ ) : ( + + )}