From c919e02441bf65fab0bb162faf4dc235c1e748e3 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 21:46:41 +0200 Subject: [PATCH] feat: enhance tenant context integration and update access layer endpoints - Implemented `library_content_visibility_sql` for managing visibility of exercises, training planning, and framework programs based on tenant context. - Updated access layer documentation to reflect changes in endpoint visibility and governance requirements. - Bumped application version to 0.8.23 in both backend and frontend files. - Enhanced changelog to document the new version and changes made in this release. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 15 ++- backend/routers/exercises.py | 44 +++---- .../routers/training_framework_programs.py | 82 +++++++------ backend/routers/training_planning.py | 111 +++++++++--------- backend/tenant_context.py | 39 ++++++ backend/version.py | 15 ++- frontend/src/version.js | 2 +- 7 files changed, 186 insertions(+), 122 deletions(-) diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 0cde5bf..8cb99d8 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -10,12 +10,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | clubs | CRUD Organisation | ja | — | `can_manage_club_org` / member | schrittweise auf TenantContext | | club_memberships | `/clubs/{id}/members*` | ja | geplant | ja | | | club_join_requests | `/clubs/{id}/join-requests*` | ja | geplant | ja | | -| exercises | `GET /api/exercises`, Detail | ja | geplant | `visibility` + Mitgliedschaft | | -| training_planning | diverse | ja | geplant | `exercise_visible` / Gruppe | | -| training_framework_programs | diverse | ja | geplant | analog Übungen | | +| exercises | `GET /api/exercises`, `POST /api/exercises` | ja | `get_tenant_context` | ja | Liste club nach aktivem Verein; POST Governance + Default club_id | +| exercises | Detail/PATCH (Übriges) | teils | `require_auth` | Owner/Admin | später Tenant optional | +| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | +| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id | | admin_users | `GET /api/admin/users` | Plattform | optional | Admin-Rolle | | | Sonstige | skills, methods, catalogs | zu klären | — | oft global | Zeilen ergänzen | **Legende:** „geplant“ = beim nächsten Umbau dieser Router `get_tenant_context` verwenden bzw. zentrale Governance-Helfer. -Letzte Änderung: 2026-05-05 (Initial) +Letzte Änderung: 2026-05-05 — Stufe B/C partiell (Bibliothekslisten + Planung); `GET /training-units` ohne automatischen club_id-Filter (Kompatibilität). + +--- + +### Hinweis `GET /training-units` + +Kein impliziter Filter nach `effective_club_id` (Multi-Verein-Kalender); bei Bedarf `club_id` Query setzen. diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 7bbfb3f..3322f73 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -16,7 +16,8 @@ from pydantic import BaseModel, Field, model_validator from db import get_db, get_cursor, r2d from auth import require_auth -from club_tenancy import exercise_visible_to_profile, is_platform_admin +from club_tenancy import assert_valid_governance_visibility, exercise_visible_to_profile, is_platform_admin +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql logger = logging.getLogger(__name__) @@ -577,14 +578,14 @@ def list_exercises( default=False, description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", ), - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """ Liste aller Übungen mit Filtern. Lightweight Response (ohne M:N Details, nur IDs und Namen). Optional include_variants für Variantenauswahl in der Trainingsplanung. """ - profile_id = session["profile_id"] + profile_id = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) @@ -593,24 +594,16 @@ def list_exercises( where = ["1=1"] params = [] - # Mandanten-Sichtbarkeit: official / eigene private / club nur eigene Vereine (Plattform-Admin: alles) - role = session.get("role") + role = tenant.global_role if not is_platform_admin(role): - where.append( - """( - e.visibility = 'official' - OR (e.visibility = 'private' AND e.created_by = %s) - OR ( - e.visibility = 'club' - AND e.club_id IS NOT NULL - AND EXISTS ( - SELECT 1 FROM club_members cm - WHERE cm.profile_id = %s AND cm.club_id = e.club_id AND cm.status = 'active' - ) - ) - )""" + vis_sql, vis_params = library_content_visibility_sql( + alias="e", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, ) - params.extend([profile_id, profile_id]) + where.append(vis_sql) + params.extend(vis_params) vis_list = _merge_str_any(visibility_any, visibility) if vis_list: @@ -789,12 +782,12 @@ def get_exercise( @router.post("/exercises", status_code=201) def create_exercise( body: ExerciseCreate, - session: dict = Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): """ Erstellt eine neue Übung mit allen M:N Relations. """ - profile_id = session["profile_id"] + profile_id = tenant.profile_id # Validierung if body.status not in ("draft", "in_review", "approved", "archived"): @@ -802,8 +795,15 @@ def create_exercise( if body.visibility not in ("private", "club", "official"): raise HTTPException(status_code=400, detail="Ungültige Visibility") + club_id = body.club_id + if body.visibility == "club" and club_id is None: + club_id = tenant.effective_club_id + with get_db() as conn: cur = get_cursor(conn) + assert_valid_governance_visibility( + cur, profile_id, tenant.global_role, body.visibility, club_id + ) # Equipment als JSONB equipment_json = json.dumps(body.equipment) if body.equipment else None @@ -822,7 +822,7 @@ def create_exercise( body.duration_min, body.duration_max, body.group_size_min, body.group_size_max, equipment_json, - body.visibility, body.status, profile_id, body.club_id, + body.visibility, body.status, profile_id, club_id, ) ) row = cur.fetchone() diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 954d693..3e38b62 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -9,7 +9,6 @@ from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, HTTPException -from auth import require_auth from club_tenancy import ( assert_valid_governance_visibility, exercise_visible_to_profile, @@ -26,6 +25,8 @@ from routers.training_planning import ( _validate_variant_for_exercise, ) +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql + router = APIRouter(prefix="/api", tags=["training_framework_programs"]) _VALID_VISIBILITY = frozenset({"private", "club", "official"}) @@ -67,6 +68,14 @@ def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dic return row +def _response_framework_detail(framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: + """Einzelabruf nach Schreiboperation (ohne FastAPI-Depends-Schleife).""" + with get_db() as conn: + cur = get_cursor(conn) + row = _framework_access(cur, framework_id, profile_id, role) + return _hydrate_framework(cur, row) + + def _training_type_ids(cur, framework_id: int) -> List[int]: cur.execute( """ @@ -308,9 +317,9 @@ def _insert_slots_and_blueprints( @router.get("/training-framework-programs") -def list_training_framework_programs(session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) base_sel = """ @@ -344,40 +353,36 @@ def list_training_framework_programs(session=Depends(require_auth)): if is_platform_admin(role): cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title") else: + vis_clause, vis_params = library_content_visibility_sql( + alias="fp", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) cur.execute( base_sel - + """ WHERE ( - fp.visibility = 'official' - OR (fp.visibility = 'private' AND fp.created_by = %s) - OR ( - fp.visibility = 'club' - AND fp.club_id IS NOT NULL - AND EXISTS ( - SELECT 1 FROM club_members cm - WHERE cm.profile_id = %s AND cm.club_id = fp.club_id AND cm.status = 'active' - ) - ) - ) + + f""" WHERE ({vis_clause}) ORDER BY fp.updated_at DESC NULLS LAST, fp.title""", - (profile_id, profile_id), + vis_params, ) return [r2d(r) for r in cur.fetchall()] @router.get("/training-framework-programs/{framework_id}") -def get_training_framework_program(framework_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") - with get_db() as conn: - cur = get_cursor(conn) - row = _framework_access(cur, framework_id, profile_id, role) - return _hydrate_framework(cur, row) +def get_training_framework_program( + framework_id: int, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role + return _response_framework_detail(framework_id, profile_id, role) @router.post("/training-framework-programs") -def create_training_framework_program(data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def create_training_framework_program( + data: dict, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme anlegen") @@ -390,6 +395,9 @@ def create_training_framework_program(data: dict, session=Depends(require_auth)) club_id = data.get("club_id") if club_id in ("", []): club_id = None + if vis == "club" and club_id is None: + club_id = tenant.effective_club_id + goals_in = data.get("goals") slots_in = data.get("slots") if not isinstance(goals_in, list) or not goals_in: @@ -432,13 +440,15 @@ def create_training_framework_program(data: dict, session=Depends(require_auth)) _replace_target_groups(cur, fid, tg_ids) conn.commit() - return get_training_framework_program(fid, session) + return _response_framework_detail(fid, profile_id, role) @router.put("/training-framework-programs/{framework_id}") -def update_training_framework_program(framework_id: int, data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def update_training_framework_program( + framework_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Keine Berechtigung") @@ -546,13 +556,15 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep conn.commit() - return get_training_framework_program(framework_id, session) + return _response_framework_detail(framework_id, profile_id, role) @router.delete("/training-framework-programs/{framework_id}") -def delete_training_framework_program(framework_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def delete_training_framework_program( + framework_id: int, tenant: TenantContext = Depends(get_tenant_context) +): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) row_fw = _fetch_framework_row(cur, framework_id) diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index e5bd24e..9e1cb95 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from db import get_db, get_cursor, r2d -from auth import require_auth +from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from club_tenancy import ( assert_valid_governance_visibility, exercise_visible_to_profile, @@ -560,9 +560,9 @@ def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any @router.get("/training-plan-templates") -def list_training_plan_templates(session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) if is_platform_admin(role): @@ -576,35 +576,30 @@ def list_training_plan_templates(session=Depends(require_auth)): """ ) else: + vis_clause, vis_params = library_content_visibility_sql( + alias="t", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) cur.execute( - """ + f""" SELECT t.*, (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) AS sections_count FROM training_plan_templates t - WHERE ( - t.visibility = 'official' - OR (t.visibility = 'private' AND t.created_by = %s) - OR ( - t.visibility = 'club' - AND t.club_id IS NOT NULL - AND EXISTS ( - SELECT 1 FROM club_members cm - WHERE cm.profile_id = %s AND cm.club_id = t.club_id AND cm.status = 'active' - ) - ) - ) + WHERE ({vis_clause}) ORDER BY t.updated_at DESC NULLS LAST, t.name """, - (profile_id, profile_id), + vis_params, ) return [r2d(r) for r in cur.fetchall()] @router.get("/training-plan-templates/{template_id}") -def get_training_plan_template(template_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def get_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) row = _template_access(cur, template_id, profile_id, role) @@ -622,9 +617,9 @@ def get_training_plan_template(template_id: int, session=Depends(require_auth)): @router.post("/training-plan-templates") -def create_training_plan_template(data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def create_training_plan_template(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen") name = (data.get("name") or "").strip() @@ -635,6 +630,8 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)): club_id = data.get("club_id") if club_id in ("", []): club_id = None + if visibility == "club" and club_id is None: + club_id = tenant.effective_club_id sections_in = data.get("sections") or [] with get_db() as conn: @@ -662,13 +659,13 @@ def create_training_plan_template(data: dict, session=Depends(require_auth)): (tid, order_ix, title, sec.get("guidance_text")), ) conn.commit() - return get_training_plan_template(tid, session) + return get_training_plan_template(tid, tenant) @router.put("/training-plan-templates/{template_id}") -def update_training_plan_template(template_id: int, data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def update_training_plan_template(template_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) row_prev = _fetch_training_plan_template_row(cur, template_id) @@ -731,13 +728,13 @@ def update_training_plan_template(template_id: int, data: dict, session=Depends( (template_id, order_ix, title, sec.get("guidance_text")), ) conn.commit() - return get_training_plan_template(template_id, session) + return get_training_plan_template(template_id, tenant) @router.delete("/training-plan-templates/{template_id}") -def delete_training_plan_template(template_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def delete_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) row_del = _fetch_training_plan_template_row(cur, template_id) @@ -760,10 +757,10 @@ def list_training_units( assigned_to_me: bool = Query(default=False), sort: str = Query(default="desc"), limit: Optional[int] = Query(default=None), - session=Depends(require_auth), + tenant: TenantContext = Depends(get_tenant_context), ): - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role gid = _optional_positive_int(group_id, "group_id") if group_id else None cid = _optional_positive_int(club_id, "club_id") if club_id else None @@ -873,9 +870,9 @@ def list_training_units( @router.get("/training-units/{unit_id}") -def get_training_unit(unit_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -937,9 +934,9 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)): @router.post("/training-units") -def create_training_unit(data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role group_id = data.get("group_id") planned_date = data.get("planned_date") @@ -996,13 +993,13 @@ def create_training_unit(data: dict, session=Depends(require_auth)): conn.commit() - return get_training_unit(unit_id, session) + return get_training_unit(unit_id, tenant) @router.put("/training-units/{unit_id}") -def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -1143,13 +1140,13 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth) conn.commit() - return get_training_unit(unit_id, session) + return get_training_unit(unit_id, tenant) @router.delete("/training-units/{unit_id}") -def delete_training_unit(unit_id: int, session=Depends(require_auth)): - profile_id = session["profile_id"] - role = session.get("role") +def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id + role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) @@ -1179,10 +1176,10 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)): @router.post("/training-units/from-framework-slot") -def create_training_unit_from_framework_slot(data: dict, session=Depends(require_auth)): +def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = Depends(get_tenant_context)): """Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id).""" - profile_id = session["profile_id"] - role = session.get("role") + profile_id = tenant.profile_id + role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen") @@ -1243,12 +1240,12 @@ def create_training_unit_from_framework_slot(data: dict, session=Depends(require conn.commit() - return get_training_unit(new_id, session) + return get_training_unit(new_id, tenant) @router.post("/training-units/quick-create") -def quick_create_training_unit(data: dict, session=Depends(require_auth)): - profile_id = session["profile_id"] +def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)): + profile_id = tenant.profile_id group_id = data.get("group_id") planned_date = data.get("planned_date") @@ -1274,7 +1271,7 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)): if not group: raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") - role = session.get("role") + role = tenant.global_role co_trainers = group["co_trainer_ids"] or [] if not _has_planning_role(role): @@ -1316,5 +1313,5 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)): conn.commit() - return get_training_unit(unit_id, session) + return get_training_unit(unit_id, tenant) diff --git a/backend/tenant_context.py b/backend/tenant_context.py index d8d9817..7181a5a 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -37,6 +37,45 @@ def parse_active_club_header(raw: Optional[str]) -> Optional[int]: return v +def library_content_visibility_sql( + *, + alias: str, + profile_id: int, + role: str, + effective_club_id: Optional[int], +) -> tuple[str, List[Any]]: + """ + WHERE-Baustein für Bibliothekslisten (Übungen, Vorlagen, Rahmenprogramme): + official, eigene private, club nur im aktiven Vereinskontext (effective_club_id). + Plattform-Admin: keine Einschränkung (TRUE). + Ohne effective_club_id: kein club-Zweig (nur official + private). + """ + if is_platform_admin(role): + return "TRUE", [] + + parts: List[str] = [ + f"{alias}.visibility = 'official'", + f"({alias}.visibility = 'private' AND {alias}.created_by = %s)", + ] + params: List[Any] = [profile_id] + + if effective_club_id is not None: + parts.append( + f"""( + {alias}.visibility = 'club' + AND {alias}.club_id IS NOT NULL + AND {alias}.club_id = %s + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s AND cm.club_id = {alias}.club_id AND cm.status = 'active' + ) + )""" + ) + params.extend([effective_club_id, profile_id]) + + return "(" + " OR ".join(parts) + ")", params + + @dataclass class TenantContext: profile_id: int diff --git a/backend/version.py b/backend/version.py index 4ede543..b728bce 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,12 +1,13 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.22" +APP_VERSION = "0.8.23" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505041" MODULE_VERSIONS = { "auth": "1.2.0", # Erster/bootstrap Nutzer und ADMIN_BOOTSTRAP_EMAILS → superadmin (nicht mehr admin) "profiles": "1.4.0", # GET /profiles/me: effective_club_id (TenantContext-Auflösung); TenantContext-Modul + "tenant_context": "1.0.0", # resolve/get_depends; library_content_visibility_sql "clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints "club_memberships": "1.0.0", "club_join_requests": "1.0.0", @@ -14,10 +15,10 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.4.0", # Multi-Tenancy: club-Sichtbarkeit nur im eigenen Verein + "exercises": "2.5.0", # list/create: TenantContext + Governance; club-Liste nach aktivem Verein "training_units": "0.1.0", "training_programs": "0.1.0", - "planning": "0.7.0", # Vorlagen + Rahmenprogramme: Listen/GET wie Übungen (visibility/club); Governance-Validierung + "planning": "0.8.0", # TenantContext auf allen Planungs-Endpunkten; Vorlagen-Liste wie Übungen nach aktivem Verein "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -26,6 +27,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.23", + "date": "2026-05-05", + "changes": [ + "ACCESS_LAYER: library_content_visibility_sql + TenantContext an Übungen-Liste, Rahmenprogramme, Trainingsplanung", + "POST Übung/Vorlage/Rahmenprogramm: visibility club ohne club_id → effective_club_id; POST Übung mit assert_valid_governance_visibility", + ], + }, { "version": "0.8.22", "date": "2026-05-05", diff --git a/frontend/src/version.js b/frontend/src/version.js index ec2be26..ce8d32a 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.22" +export const APP_VERSION = "0.8.23" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = {