Update Version and Enhance Club Creation Request Management
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
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) Successful in 1m20s

- Incremented application version to 0.8.192 and database schema version to 20260606081.
- Updated club module versions for 'clubs' and 'club_creation_requests' to reflect recent changes.
- Implemented logic to mark approved club creation requests as 'superseded' when the associated club is deleted.
- Refactored frontend components to clear session storage for coach-related keys upon logout and during login checks.
- Enhanced onboarding page to accurately display the status of club creation requests based on their validity.
This commit is contained in:
Lars 2026-06-07 07:31:05 +02:00
parent 37785135b1
commit fa10450315
9 changed files with 71 additions and 13 deletions

View File

@ -0,0 +1,13 @@
-- Migration 081: Status superseded wenn freigegebener Verein gelöscht wurde
ALTER TABLE club_creation_requests
DROP CONSTRAINT IF EXISTS club_creation_requests_status_check;
ALTER TABLE club_creation_requests
ADD CONSTRAINT club_creation_requests_status_check
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn', 'superseded'));
-- Bestehende Drift: approved ohne Verein (ON DELETE SET NULL auf created_club_id)
UPDATE club_creation_requests
SET status = 'superseded', updated_at = NOW()
WHERE status = 'approved' AND created_club_id IS NULL;

View File

@ -112,6 +112,14 @@ class CreationRequestCreate(BaseModel):
message: Optional[str] = Field(None, max_length=2000) message: Optional[str] = Field(None, max_length=2000)
def _normalize_creation_request_row(row: Dict[str, Any]) -> Dict[str, Any]:
"""Approved ohne Verein → superseded (z. B. nach Vereinslöschung, FK SET NULL)."""
d = dict(row)
if d.get("status") == "approved" and not d.get("created_club_id"):
d["status"] = "superseded"
return d
def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
cur.execute( cur.execute(
""" """
@ -125,7 +133,7 @@ def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Antrag nicht gefunden") raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
return r2d(row) return _normalize_creation_request_row(r2d(row))
def _assert_platform_admin(tenant: TenantContext) -> None: def _assert_platform_admin(tenant: TenantContext) -> None:
@ -157,7 +165,7 @@ def get_my_creation_requests(tenant: TenantContext = Depends(get_tenant_context)
""", """,
(pid,), (pid,),
) )
return [r2d(r) for r in cur.fetchall()] return [_normalize_creation_request_row(r2d(r)) for r in cur.fetchall()]
@router.post("/me/club-creation-requests", status_code=201) @router.post("/me/club-creation-requests", status_code=201)

View File

@ -336,6 +336,16 @@ def delete_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context
if not cur.fetchone(): if not cur.fetchone():
raise HTTPException(404, "Verein nicht gefunden") raise HTTPException(404, "Verein nicht gefunden")
# Gründungsanträge: Freigabe verliert Gültigkeit wenn Verein entfernt wird
cur.execute(
"""
UPDATE club_creation_requests
SET status = 'superseded', updated_at = NOW()
WHERE created_club_id = %s AND status = 'approved'
""",
(club_id,),
)
# Delete (CASCADE handles divisions and groups) # Delete (CASCADE handles divisions and groups)
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,)) cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
conn.commit() conn.commit()

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.191" APP_VERSION = "0.8.192"
BUILD_DATE = "2026-06-06" BUILD_DATE = "2026-06-06"
DB_SCHEMA_VERSION = "20260606080" DB_SCHEMA_VERSION = "20260606081"
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)
@ -11,10 +11,10 @@ MODULE_VERSIONS = {
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.0.1", # resolve_capabilities_map für /me/entitlements "capabilities": "1.0.1", # resolve_capabilities_map für /me/entitlements
"account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware "account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "clubs": "0.4.2", # delete_club: Gründungsanträge → superseded
"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 "club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
"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

View File

@ -9,6 +9,7 @@ import {
} from 'react' } from 'react'
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api' import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub' import { activeClubMemberships } from '../utils/activeClub'
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@ -131,11 +132,7 @@ export function AuthProvider({ children }) {
setUser(null) setUser(null)
localStorage.removeItem('authToken') localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY) localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
for (const key of Object.keys(sessionStorage)) { clearCoachSessionStorage()
if (key.startsWith('sj_coach_')) {
sessionStorage.removeItem(key)
}
}
}, []) }, [])
const value = useMemo( const value = useMemo(

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
function LoginPage() { function LoginPage() {
const [mode, setMode] = useState('login') // 'login' or 'register' const [mode, setMode] = useState('login') // 'login' or 'register'
@ -18,6 +19,12 @@ function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { checkAuth } = useAuth() const { checkAuth } = useAuth()
useEffect(() => {
if (!localStorage.getItem('authToken')) {
clearCoachSessionStorage()
}
}, [])
useEffect(() => { useEffect(() => {
if (mode !== 'register') return if (mode !== 'register') return
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => setPublicClubs([])) api.listPublicClubsDirectory().then(setPublicClubs).catch(() => setPublicClubs([]))

View File

@ -19,8 +19,14 @@ const creationStatusLabel = (s) =>
approved: 'freigegeben', approved: 'freigegeben',
rejected: 'abgelehnt', rejected: 'abgelehnt',
withdrawn: 'zurückgezogen', withdrawn: 'zurückgezogen',
superseded: 'Verein entfernt',
})[s] || s })[s] || s
/** Freigabe noch gültig (Verein existiert). */
function isActiveApprovedCreation(req) {
return req.status === 'approved' && req.created_club_id
}
/** /**
* Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A). * Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A).
*/ */
@ -264,7 +270,7 @@ export default function OnboardingPage() {
{myCreationRequests.map((r) => ( {myCreationRequests.map((r) => (
<li key={r.id} style={{ marginBottom: '0.35rem' }}> <li key={r.id} style={{ marginBottom: '0.35rem' }}>
{r.proposed_name} {creationStatusLabel(r.status)} {r.proposed_name} {creationStatusLabel(r.status)}
{r.status === 'approved' && r.created_club_name {isActiveApprovedCreation(r) && r.created_club_name
? ` (${r.created_club_name})` ? ` (${r.created_club_name})`
: null} : null}
{r.status === 'pending' ? ( {r.status === 'pending' ? (
@ -288,7 +294,7 @@ export default function OnboardingPage() {
</button> </button>
</> </>
) : null} ) : null}
{r.status === 'approved' ? ( {isActiveApprovedCreation(r) ? (
<> <>
{' '} {' '}
<button <button

View File

@ -283,6 +283,17 @@ export function coachBranchPicksStorageKey(unitId) {
return `sj_coach_branches_${unitId}` return `sj_coach_branches_${unitId}`
} }
/** Coach-Sitzung (P-12): alle sj_coach_*-Keys aus sessionStorage entfernen. */
export function clearCoachSessionStorage() {
if (typeof sessionStorage === 'undefined') return
for (let i = sessionStorage.length - 1; i >= 0; i -= 1) {
const k = sessionStorage.key(i)
if (k && k.startsWith('sj_coach_')) {
sessionStorage.removeItem(k)
}
}
}
/** /**
* Index zum Springen: Co-Trainer-Link atBranch+preferSo Gate falls noch offen, sonst erste Kachel im Stream. * Index zum Springen: Co-Trainer-Link atBranch+preferSo Gate falls noch offen, sonst erste Kachel im Stream.
*/ */

View File

@ -368,7 +368,13 @@ test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', a
// App.jsx DesktopSidebar-Logout zeigt confirm() — in Playwright headless akzeptieren // App.jsx DesktopSidebar-Logout zeigt confirm() — in Playwright headless akzeptieren
page.once('dialog', dialog => dialog.accept()); page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Abmelden' }).click(); await page.getByRole('button', { name: 'Abmelden' }).click();
await page.waitForURL((url) => url.pathname.includes('/login'), { timeout: 15000 });
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Nach Hard-Redirect: LoginPage bereinigt sj_coach_* wenn kein Token
await page.waitForFunction(
() => sessionStorage.getItem('sj_coach_step_42') === null,
{ timeout: 10000 },
);
const nachLogout = await page.evaluate(() => ({ const nachLogout = await page.evaluate(() => ({
step: sessionStorage.getItem('sj_coach_step_42'), step: sessionStorage.getItem('sj_coach_step_42'),