Compare commits

...

31 Commits

Author SHA1 Message Date
4724da28b1 Merge pull request 'Progressionsgraph verbessert' (#54) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 48s
Reviewed-on: #54
2026-06-09 16:37:22 +02:00
d4b1780193 Enhance Gap Fill Offer with Context Preview and Update Version
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
- Added `context_preview` to the `build_gap_fill_offer` function, providing a structured overview of the roadmap snapshot.
- Introduced `gapOfferContextDisplayLines` utility to format context information for UI display, improving clarity for users.
- Updated `ExerciseProgressionPathBuilder` and related components to utilize the new context preview, enhancing the user experience.
- Incremented application version to 0.8.213 to reflect these changes.
2026-06-09 16:27:03 +02:00
f2650dac57 Enhance Planning Context with Progression Gap Snapshot and Start/Target Analysis
All checks were successful
Deploy Development / deploy (push) Successful in 42s
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 35s
Test Suite / playwright-tests (push) Successful in 1m13s
- Introduced `build_progression_gap_snapshot` function to create a compact roadmap context for gap exercises, integrating start situation, target state, and stage specifications.
- Updated `build_gap_fill_goal_text` to include roadmap snapshot details, enhancing the context for AI-generated exercises.
- Enhanced `ProgressionPathSuggestRequest` and related components to support new structured inputs for start/target analysis, improving user experience and AI suggestions.
- Incremented application version to 0.8.212 to reflect these changes.
2026-06-09 16:22:16 +02:00
fad1058d54 Enhance Progression Path Features with LLM Start/Target Extraction
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
- Added `include_llm_start_target` option to `ProgressionPathSuggestRequest` for improved roadmap suggestions.
- Introduced new classes `StartTargetExtractArtifact` and `StartTargetResolveMeta` to handle LLM extraction results and metadata.
- Implemented `try_llm_start_target_extract` function to extract start and target states from goal queries using LLM.
- Updated `resolve_roadmap_structured_input` to prioritize user inputs, LLM extractions, and regex parsing for start/target resolution.
- Enhanced `ExerciseProgressionPathBuilder` to utilize new structured inputs and display extraction sources.
- Incremented application version to 0.8.211 to reflect these changes.
2026-06-09 12:54:08 +02:00
9dd44ce3ca Add Structured Roadmap Inputs and Enhance Goal Analysis Features
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
- Introduced `RoadmapStructuredInput` to encapsulate structured inputs for start situation, target state, and roadmap notes.
- Updated `ProgressionPathSuggestRequest` to include new fields for structured roadmap inputs.
- Implemented parsing logic for goal queries to extract start and target states, enhancing the goal analysis process.
- Enhanced `build_goal_analysis` to utilize structured inputs, improving the clarity and relevance of generated goals.
- Updated the `ExerciseProgressionPathBuilder` component to support new structured input fields, enhancing user experience.
- Incremented application version to 0.8.210 to reflect these changes.
2026-06-09 11:10:46 +02:00
87f258be38 Enhance Path QA with Roadmap-First Features and Gap Detection Improvements
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m15s
- Introduced `roadmap_qa_mode` to manage QA behavior based on roadmap-first logic, improving gap detection between major steps.
- Updated `detect_path_gaps` to skip gaps for roadmap-planned neighbor pairs, enhancing the accuracy of path assessments.
- Added new helper function `is_roadmap_planned_neighbor_pair` to facilitate roadmap neighbor checks.
- Updated relevant tests to validate new functionality and ensure robustness.
- Incremented application version to 0.8.209 to reflect these changes.
2026-06-09 10:17:30 +02:00
779e2477ba Implement Planning Context Integration for Exercise AI Suggestions
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
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 1m13s
- Added `planning_context` to the `suggestExerciseAi` endpoint, enabling structured planning context for new exercise creation.
- Updated relevant components and backend logic to handle the new planning context, enhancing the AI's exercise suggestion capabilities.
- Incremented application version to 0.8.208 to reflect these changes.
2026-06-08 15:15:03 +02:00
f074a8bef0 Implement Roadmap Review Features and Enhance Progression Path Management
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
- Added support for editable major steps in the roadmap, allowing users to modify phase, learning goals, and order before exercise matching.
- Introduced a new `roadmap_override` feature to facilitate customized retrieval without re-invoking the roadmap AI.
- Updated the `ExerciseProgressionPathBuilder` component to incorporate these new features, enhancing user interaction and flexibility.
- Incremented application version to 0.8.207 to reflect these changes.
2026-06-08 14:59:24 +02:00
0677663268 Enhance Exercise Progression Path Management with Dynamic Step Capacity
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 46s
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 1m13s
- Introduced logic to manage path capacity dynamically, allowing users to expand the maximum number of steps when inserting new offers.
- Implemented confirmation prompts for users when the path is full, enhancing user experience and decision-making.
- Updated the `ExerciseProgressionPathBuilder` component to reflect these changes, improving the handling of gap-fill offers and user interactions.
- Adjusted UI messages to clarify the implications of adding new steps and the conditions under which users can expand the path.
2026-06-08 14:51:15 +02:00
d4e9bded23 Implement Roadmap-First Retrieval and Enhance Planning AI Features
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m26s
- Introduced a roadmap-first approach for retrieval, allowing for structured exercise suggestions based on stage specifications and major steps.
- Added functionality to generate gap-fill offers for unfilled roadmap stages, improving the relevance of exercise recommendations.
- Updated the `ExerciseProgressionPathBuilder` to support the new roadmap-first feature, enhancing user experience with clearer exercise paths.
- Incremented application version to 0.8.206 and updated the database schema version to reflect these changes.
2026-06-08 12:40:17 +02:00
7411543a97 Enhance Planning AI with Roadmap-First Architecture and New Features
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 40s
Test Suite / playwright-tests (push) Successful in 1m14s
- Introduced a roadmap-first approach for the planning AI, allowing for a structured progression graph that aligns with the overall project roadmap.
- Updated the `ExerciseProgressionPathBuilder` to include a roadmap preview and improved handling of focus areas and skills catalog.
- Added functionality to strip off-topic steps from the exercise path, enhancing the relevance of generated paths.
- Implemented a new method to build detailed goal texts for AI-generated exercises, improving clarity and context.
- Incremented application version to 0.8.205 and updated database schema version to 20260606086 to reflect these changes.
2026-06-08 08:23:33 +02:00
dd0fae4bf5 Enhance Planning AI with Roadmap-First Architecture and New Features
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 44s
Test Suite / playwright-tests (push) Successful in 1m15s
- Introduced a roadmap-first approach for the planning AI, allowing for a structured progression graph that aligns with the overall project roadmap.
- Added new functionality to strip off-topic steps from exercise paths, improving the relevance of generated exercise suggestions.
- Implemented a detailed goal text generation for AI proposals, enhancing the context provided for new exercises.
- Updated the ExerciseProgressionPathBuilder component to support new features, including roadmap previews and improved focus area handling.
- Incremented application version to 0.8.205 and updated database schema version to 20260606086 to reflect these changes.
2026-06-08 08:10:53 +02:00
a9a6153ed5 Implement Club Feature Enforcement Logic and Update Versioning
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 43s
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 1m15s
- Introduced a new environment variable `CLUB_FEATURE_ENFORCE` to control club feature access, allowing values of 1, true, or yes for activation.
- Updated the backend logic to check for club feature enforcement, raising HTTP exceptions when access is denied without an active club context.
- Enhanced the admin rights router with a new endpoint to check the enforcement status of club features.
- Incremented application version to 0.8.202 to reflect these changes.
2026-06-07 15:47:49 +02:00
4130a63dfe Implement Registry-First Approach for Rights and Capabilities Management
All checks were successful
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 35s
Test Suite / playwright-tests (push) Successful in 1m51s
- Updated the capability catalog to reflect a registry-first approach, requiring modules to register rights and quotas upon implementation.
- Enhanced the backend to synchronize the rights registry with the database, ensuring only registered capabilities and features are displayed in the admin matrix.
- Modified SQL queries in the admin rights router to filter capabilities and features based on module registration.
- Updated documentation to clarify the new rights and features registry process, replacing the previous catalog-first method.
- Incremented application version to 0.8.201 and updated database schema version to 20260606084 to reflect these changes.
2026-06-07 15:36:31 +02:00
9d52aeab67 Update Membership RBAC Decisions and Enhance Admin Rights Management
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
- Updated the Membership RBAC Decisions document to reflect the latest implementation status and roadmap, including new features and enhancements.
- Incremented application version to 0.8.200 and updated database schema version to 20260606083.
- Added a new API endpoint to clear capability grants for club roles, improving admin rights management.
- Enhanced the Admin Rights page in the frontend to display enforcement status and feature consumption details for capabilities.
- Improved the user interface for better clarity on rights and capabilities management.
2026-06-07 15:27:37 +02:00
b68185842e Enhance Club Feature Consumption Logic and Update Versioning
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m40s
- Introduced the `consume_club_feature_with_usage` function to standardize feature consumption across endpoints, improving code reusability and clarity.
- Implemented `merge_feature_usage_into_response` to embed feature usage data in API responses, streamlining frontend integration.
- Updated various backend routers to utilize the new consumption logic, ensuring consistent feature usage tracking during AI-related actions.
- Enhanced tests to validate the new consumption and logging behavior.
- Incremented application version to 0.8.199 and updated module version for 'club_features' to 1.6.0 to reflect these changes.
2026-06-07 10:32:49 +02:00
40641594ac Add admin rights router to access layer hints exemption list
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 36s
Test Suite / playwright-tests (push) Successful in 1m20s
- Included "admin_rights.py" in the EXEMPT_ROUTERS frozenset to ensure proper access control for superadmin roles.
- This change enhances the management of admin capabilities and aligns with recent updates to the admin rights management system.
2026-06-07 09:24:16 +02:00
e4cb491d46 Refactor Admin Rights Management and Update Versioning
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Has been cancelled
- Replaced the admin club feature exemptions router with a new admin rights router to streamline capability management.
- Added new API endpoints for managing admin rights, including capability grants and quota bypass for portal roles and profiles.
- Updated the frontend to include navigation and lazy loading for the new Admin Rights page.
- Incremented application version to 0.8.197 to reflect these changes and enhancements.
2026-06-07 09:21:59 +02:00
8404a42b6c Implement Club Feature Quota Bypass and Update Versioning
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 42s
Test Suite / playwright-tests (push) Successful in 1m19s
- Added support for club feature quota bypass based on portal roles and profile grants in the capabilities check.
- Introduced new functions to handle quota bypass logic in club feature access and consumption.
- Updated the FeatureUsageBadge component to reflect platform exemptions for features.
- Incremented application version to 0.8.195 and database schema version to 20260606083 to reflect these changes.
- Enhanced backend routers to include new logic for consuming club features during AI-related actions.
2026-06-07 07:43:35 +02:00
fa10450315 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.
2026-06-07 07:31:05 +02:00
37785135b1 Refactor Org Inbox Context and Enhance Club Creation Management
Some checks failed
Deploy Development / deploy (push) Successful in 47s
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 1m21s
- Updated the OrgInboxContext to include handling for club creation requests, allowing for better management of inbox items.
- Refactored components to utilize the new `canShowInboxNav` and `canAccessClubCreationInbox` flags for improved access control.
- Enhanced the InboxPage to display club creation requests with appropriate actions for approval and rejection.
- Updated the DashboardOrgInboxWidget to show both club creation and join requests, improving the user interface for managing inbox items.
2026-06-07 07:18:43 +02:00
8ee8f52e0f 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
- 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.
2026-06-07 07:09:39 +02:00
8718cf5c70 Enhance Authentication and Feature Usage Handling
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 1m15s
- Refactored the logout function in AuthContext to handle asynchronous logout operations, improving session management.
- Updated the FeatureUsageBadge component to display error messages when feature data retrieval fails, enhancing user feedback.
- Replaced lazy loading of OnboardingPage with lazyWithRetry for improved loading reliability.
- Adjusted the EntitlementsContext to determine club ID using utility functions for better governance form handling.
2026-06-07 07:05:02 +02:00
91dae7b614 Update Gitea Workflow to Restrict Test Triggers
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 41s
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 1m14s
- Modified the Gitea workflow configuration to trigger tests only on pushes and pull requests to the 'develop' branch, preventing duplicate test runs on the 'main' branch during merges.
- Added comments to clarify the purpose of the workflow triggers for better understanding.
2026-06-07 06:50:36 +02:00
20927a5969 Fix capability ID reference in migration script
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m15s
- Updated the SQL migration script to select the correct capability ID from the capabilities table, ensuring accurate role capability grants during database migrations.
2026-06-07 06:43:01 +02:00
7db77f4738 Improve Deployment Workflow and Database Migration Logic
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Failing after 43s
Test Suite / pytest-backend (push) Failing after 31s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
- Enhanced the deployment workflow to include error handling for the DEV API, ensuring logs are captured if the API is unreachable.
- Updated the migration scripts to safely rename existing tables by checking for their existence, preventing potential conflicts during migrations.
- Added exception handling in migration 079 to ensure the prerequisites are met before proceeding with the creation of the capabilities table.
2026-06-07 06:41:22 +02:00
3e87f7515a Enhance Backend Testing Workflow and API Onboarding Logic
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Has been skipped
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / playwright-tests (push) Has been cancelled
Test Suite / k6 /health Baseline (push) Has been cancelled
- Updated the Gitea workflow to ensure backend tests run only on successful workflow runs or pull requests, improving CI reliability.
- Added a timeout mechanism to wait for the backend container to be ready before executing tests, enhancing test stability.
- Refactored the onboarding gate logic to include a middleware session lookup check, ensuring proper access control based on database state.
- Improved code readability and added comments for better understanding of the onboarding gate functionality.
2026-06-07 06:26:46 +02:00
a2f60d3f46 Update Capability Catalog and Club Membership Documentation
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / playwright-tests (push) Has been cancelled
Test Suite / k6 /health Baseline (push) Has been cancelled
- Revised the status in the Capability Catalog to reflect partial implementation (M3).
- Added a new reference to `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` in both the Capability Catalog and Club Membership documentation.
- Enhanced the Club Membership documentation with details on product decisions and onboarding phases.
- Implemented middleware in the backend to restrict access for unverified users and those pending club membership.
- Updated versioning in `version.py` to reflect changes in account lifecycle management.
2026-06-07 05:57:13 +02:00
30dc30c7aa Enhance Tenant Context and Access Control Features
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Failing after 4m0s
Test Suite / playwright-tests (push) Failing after 3m41s
- Introduced `email_verified` and `account_state` attributes in the `TenantContext` to improve user state management.
- Updated the `resolve_tenant_context` function to dynamically fetch `email_verified` status from the database and determine `account_state` based on user roles and memberships.
- Implemented `assert_min_account_state` checks across various endpoints to enforce access control based on user account status.
- Incremented version to 1.1.0 in version.py to reflect these enhancements in tenant context management and access control.
2026-06-06 21:10:52 +02:00
7cfbca40bb Implement Club Feature Access Probing and Inventory Count
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m42s
- Introduced `probe_club_feature_access` to check club feature limits and log access attempts without blocking by default.
- Added `_live_inventory_count` function to retrieve current counts for specific features, enhancing feature limit management.
- Updated various endpoints to utilize the new probing functionality, ensuring compliance with club feature access rules.
- Incremented version to 1.1.0 in version.py to reflect these enhancements in club feature management.
2026-06-06 21:00:42 +02:00
c294c27de8 Update Access Layer and Governance Documentation
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m12s
- Enhanced the ACCESS_LAYER_AND_GOVERNANCE_PLAN.md with new specifications for capability documentation and community features.
- Added references to new documents detailing capability IDs and club membership features.
- Updated MULTI_TENANCY_RBAC_ARCHITECTURE.md to include links to the new specifications.
- Marked certain features as deprecated in backend/auth.py, indicating migration paths for club feature access.
- Incremented DB_SCHEMA_VERSION to 20260606078 in version.py to reflect recent changes.
2026-06-06 20:44:51 +02:00
107 changed files with 12665 additions and 233 deletions

View File

@ -79,16 +79,18 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
### Stufe E Capabilities dokumentieren (ohne UI für Custom Roles)
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `content.share_club`, `planning.edit_unit`, `org.manage_members`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen.
- **Verbindliche Spez v1:** `CAPABILITY_CATALOG.v1.md` — Capability-IDs, Account-Lifecycle, Rollen-Matrix, Endpoint-Mapping.
- Markdown-Tabelle **Capability-Fingerprint**: Kennungen wie `exercises.ai.suggest`, `org.members.manage`, … mit Zuordnung zu den **heutigen** festen Vereinsrollen (siehe Katalog §56).
- Ziel: später `club_custom_roles` nur noch andere Kombination derselben Kennungen keine zweite Philosophie.
### Stufe F Community (eigenes Epic)
- Konzept: Freigabe **additiv** (Flag oder Enum), Moderation, Sichtbarkeit „öffentlich außerhalb meines Vereins“ ohne bestehende `club`-Isolation zu brechen.
### Zurückgestellt Vereinsabo / Limits
### Zurückgestellt Vereinsabo / Limits (Konzept liegt vor)
- Wiederöffnen wenn ACCESS_LAYER Stufe C/D stabil; dann Enforcement vor ausgewählten Writes an einen Billing-Stripe binden.
- **Spez v1:** `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` — Feature-Registry (Mitai-v9c-Pattern), `club_plans`/`club_subscriptions`, Kontingente an `club_id`.
- Implementierung/Billing (Stripe) weiter zurückgestellt; Schema- und Enforcement-Hooks gemäß 4-Phasen-Rollout (Mitai-Vorbild) vorbereiten, sobald Stufe C/D stabil.
---
@ -117,6 +119,8 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
## 7. Referenzen
- **`CAPABILITY_CATALOG.v1.md`** Rollen, Capabilities, CRUD-Mapping, `GET /api/me/entitlements`.
- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** Vereinsabo, Feature-Limits, Mitai-Mapping, Ziel-Schema.
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` übergeordnetes Zielbild & Begriffe.
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
- `backend/club_tenancy.py` bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, `can_plan_in_club`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.

View File

@ -0,0 +1,331 @@
# Capability-Katalog Shinkan v1
**Status:** Konzept (verbindliche Zieldefinition; M3 teilweise umgesetzt)
**Stand:** 2026-06-06
**Bezüge:** `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` (Stufe E), `MULTI_TENANCY_RBAC_ARCHITECTURE.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`** (Produktentscheidungen)
---
## 1. Zweck
Dieses Dokument definiert **benannte Capabilities** (Wer darf welche **Funktion** ausführen?) — getrennt von:
- **Governance** (Darf ich *dieses Objekt* lesen/ändern? → `visibility`, `club_id`, `created_by`)
- **Feature-Limits** (Wie viel darf der **Verein**? → `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`)
Capabilities beantworten: *„Darf ein Trainer mit Rolle X die Funktion Y im Verein Z überhaupt nutzen?“*
---
## 2. Namenskonvention
```
{domain}.{action}[.{qualifier}]
```
| Segment | Beispiele |
|---------|-----------|
| `domain` | `exercises`, `media`, `planning`, `org`, `platform` |
| `action` | `read`, `create`, `update`, `delete`, `manage`, `execute` |
| `qualifier` | `ai.suggest`, `join_request`, `inbox.review` |
**CRUD-Mapping:**
| Aktion | Capability-Suffix | Bedeutung |
|--------|-------------------|-----------|
| Lesen (Listen/Detail) | `.read` | Navigation + API-Lesen erlaubt |
| Anlegen | `.create` | POST/INSERT |
| Bearbeiten | `.update` | PUT/PATCH (eigenes + berechtigtes Fremdes) |
| Löschen | `.delete` | DELETE (strenger als update) |
| Verwalten | `.manage` | Org-Funktionen, Freigaben, Mitglieder |
| Ausführen (ohne Persistenz) | `.execute` | z. B. KI-Vorschau, Coach-Lauf |
Objektbezogene Feinheiten (nur Ersteller, nur Vereinsadmin des Objekt-Vereins) bleiben in **Governance** — Capabilities sind das **Tür-Schloss** davor.
---
## 3. Account-Lifecycle (Voraussetzung für Capabilities)
| `account_state` | Bedingung | Typische Capabilities |
|-----------------|-----------|------------------------|
| `anonymous` | Keine Session | nur öffentliche Routen (`/login`, Rechtstexte, `clubs/public-directory`) |
| `unverified` | Session, `email_verified=false` | `account.resend_verification`, `account.logout` |
| `verified_pending_club` | Verifiziert, keine aktive `club_members` | `club.join_request`, `club.creation_request` (M7), `account.settings`**kein** Lesezugriff auf Domänen-Inhalte (siehe Entscheidungs-Doc §1.1) |
| `active_member` | Mind. eine aktive Vereinsmitgliedschaft | Domänen-Capabilities gemäß Vereinsrolle |
| `platform_admin` | `role``admin`, `superadmin` | `platform.*` zusätzlich |
**Regel:** Domänen-Capabilities (`exercises.*`, `planning.*`, …) erfordern mindestens `active_member`, sofern nicht `platform_admin`.
---
## 4. Rollen-Scopes
### 4.1 Portal-Rollen (`profiles.role`)
| Rolle | Scope | Kurz |
|-------|-------|------|
| `user` | Portal | Standard nach Registrierung (Zielbild; heute oft `trainer` Legacy) |
| `trainer` | Portal | Legacy — mittelfristig durch `user` + Vereinsrollen ersetzen |
| `admin` | Portal | Plattform-Admin (Vereine anlegen, erweiterte Ops) |
| `superadmin` | Portal | Vollzugriff Plattform + Superadmin-Werkzeuge |
### 4.2 Vereinsrollen (`club_member_roles.role_code`)
| Rolle | Fachlich |
|-------|----------|
| `club_admin` | Vereinsorganisation, Mitglieder, Struktur |
| `trainer` | Planung, Übungen, Durchführung |
| `content_editor` | Inhalte pflegen (Bibliothek) |
| `division_lead` | Spartenleitung (später division-scope) |
Mehrfachrollen pro Mitgliedschaft sind möglich (OR-Verknüpfung der Capabilities).
### 4.3 Mapping heutiger Helfer → Capabilities
| Heutiger Code (`club_tenancy.py`) | Ziel-Capability-Cluster |
|-----------------------------------|-------------------------|
| `can_manage_club_org` | `org.structure.manage`, `org.members.manage`, `org.inbox.review` |
| `can_plan_in_club` | `planning.*`, `exercises.create/update`, `modules.*`, `framework.*` |
| `is_platform_admin` | `platform.*` (Bypass Mandant, Audit-Pflicht) |
| `is_superadmin` | `platform.superadmin.*` |
---
## 5. Capability-Katalog (v1)
Legende Spalten:
- **Min. Account:** `verified_pending_club` | `active_member` | `platform_admin`
- **Vereinsrollen:** leer = alle aktiven Mitglieder; sonst mindestens eine Rolle
- **Feature-ID:** optionales Kontingent (siehe Club-Membership-Doc); leer = kein Limit
- **Governance:** zusätzliche Objektprüfung ja/nein
### 5.1 Account & Onboarding
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI |
|---------------|--------------|---------------|------------|----------------|
| `account.settings.read` | `unverified` | — | — | `GET /profiles/me`, Einstellungen |
| `account.settings.update` | `unverified` | — | — | `PUT /profiles/{id}` (eigenes Profil) |
| `account.password.change` | `unverified` | — | — | `PUT /api/auth/pin` |
| `account.resend_verification` | `unverified` | — | — | `POST /api/auth/resend-verification` |
| `club.directory.read` | `verified_pending_club` | — | — | `GET /clubs/public-directory` |
| `club.join_request.create` | `verified_pending_club` | — | — | `POST /me/club-join-requests`, Registrierung mit `requested_club_id` |
| `club.join_request.withdraw` | `verified_pending_club` | — | — | `DELETE /me/club-join-requests/{id}` |
| `club.join_request.read_own` | `verified_pending_club` | — | — | `GET /me/club-join-requests` |
### 5.2 Organisation (Verein)
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Endpoints / UI |
|---------------|--------------|---------------|------------|----------------|
| `org.club.read` | `active_member` | * | — | `GET /clubs`, `GET /clubs/{id}` (eigene Vereine) |
| `org.club.create` | `platform_admin` | — | — | `POST /clubs` |
| `org.club.update` | `platform_admin` | `club_admin` | — | `PUT /clubs/{id}` |
| `org.club.delete` | `platform_admin` | — | — | `DELETE /clubs/{id}` |
| `org.structure.manage` | `active_member` | `club_admin` | `training_groups` | Sparten, Gruppen CRUD |
| `org.members.read` | `active_member` | `club_admin` | — | `GET /clubs/{id}/members` |
| `org.members.manage` | `active_member` | `club_admin` | `active_members` | POST/PUT/DELETE Mitglieder |
| `org.members.directory` | `active_member` | * | — | `GET /clubs/{id}/members/directory` (ohne E-Mail für Nicht-Admins) |
| `org.join_request.review` | `active_member` | `club_admin` | — | Join-Request accept/reject, Inbox |
| `org.inbox.read` | `active_member` | `club_admin` | — | Posteingang Join + Content-Reports |
### 5.3 Übungen
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|---------------|--------------|---------------|------------|------------|
| `exercises.read` | `active_member` | * | — | ja (visibility) |
| `exercises.create` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | `exercises` | — |
| `exercises.update` | `active_member` | `trainer`, `content_editor`, `club_admin`, `division_lead` | — | ja |
| `exercises.delete` | `active_member` | `club_admin` (+ Ersteller privat) | — | ja |
| `exercises.bulk_metadata` | `active_member` | `content_editor`, `club_admin` | — | ja |
| `exercises.ai.suggest` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | — |
| `exercises.ai.regenerate` | `active_member` | `trainer`, `content_editor`, `club_admin` | `ai_calls` | ja (edit) |
| `exercises.media.read` | `active_member` | * | — | ja |
| `exercises.media.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja |
| `exercises.variants.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
**Representative Endpoints:** `/api/exercises*`, `/api/exercises/ai/*`, Medien-Datei-Download.
### 5.4 Medien-Bibliothek (Archiv)
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|---------------|--------------|---------------|------------|------------|
| `media.library.read` | `active_member` | * | — | ja |
| `media.library.upload` | `active_member` | `trainer`, `content_editor`, `club_admin` | `exercise_media` | ja |
| `media.library.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
| `media.library.lifecycle` | `active_member` | `trainer`, `club_admin` | — | ja |
| `media.rights.declare` | `active_member` | `trainer`, `club_admin` | — | ja |
| `media.admin.rights_review` | `platform_admin` | — | — | Plattform-Admin Legacy-Review |
### 5.5 Trainingsmodule & Rahmenprogramme
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|---------------|--------------|---------------|------------|------------|
| `modules.read` | `active_member` | * | — | ja |
| `modules.create` | `active_member` | `trainer`, `content_editor`, `club_admin` | `training_programs` | — |
| `modules.update` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
| `modules.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja |
| `framework.read` | `active_member` | * | — | ja |
| `framework.create` | `active_member` | `trainer`, `club_admin` | `training_programs` | — |
| `framework.update` | `active_member` | `trainer`, `club_admin` | — | ja |
| `framework.delete` | `active_member` | `club_admin` (+ Ersteller) | — | ja |
| `plan_templates.read` | `active_member` | * | — | ja |
| `plan_templates.manage` | `active_member` | `trainer`, `club_admin` | — | ja |
### 5.6 Progressionspfade
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|---------------|--------------|---------------|------------|------------|
| `progression.read` | `active_member` | * | — | ja |
| `progression.manage` | `active_member` | `trainer`, `content_editor`, `club_admin` | — | ja |
### 5.7 Planung & Durchführung
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|---------------|--------------|---------------|------------|------------|
| `planning.calendar.read` | `active_member` | * | — | ja (Gruppe/Verein) |
| `planning.units.create` | `active_member` | `trainer`, `club_admin`, `division_lead` | `training_units` | ja |
| `planning.units.update` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja |
| `planning.units.delete` | `active_member` | `club_admin`, `trainer` (eigene) | — | ja |
| `planning.units.run` | `active_member` | `trainer`, `club_admin`, `division_lead` | — | ja |
| `planning.coach.execute` | `active_member` | `trainer`, `club_admin` | — | ja |
| `planning.ai.suggest` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — |
| `planning.ai.progression_path` | `active_member` | `trainer`, `club_admin` | `ai_calls` | — |
### 5.8 Fähigkeiten & Scoring
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID | Governance |
|---------------|--------------|---------------|------------|------------|
| `skills.catalog.read` | `active_member` | * | — | globaler Katalog |
| `skills.discovery.read` | `active_member` | `trainer`, `content_editor` | — | — |
| `skill_profiles.read` | `active_member` | * | — | ja (Artefakt) |
### 5.9 Governance & Meldungen
| Capability-ID | Min. Account | Vereinsrollen | Feature-ID |
|---------------|--------------|---------------|------------|
| `governance.content_report.create` | `active_member` | * | — |
| `governance.content_report.review` | `active_member` | `club_admin` | — |
| `governance.change_request.*` | `active_member` | `content_editor`, `club_admin` | — |
### 5.10 Plattform (nur Portal-Admin / Superadmin)
| Capability-ID | Min. Account | Portal-Rolle | Feature-ID |
|---------------|--------------|--------------|------------|
| `platform.admin.access` | `platform_admin` | `admin`, `superadmin` | — |
| `platform.users.manage` | `platform_admin` | `superadmin` | — |
| `platform.catalogs.manage` | `platform_admin` | `superadmin` | — |
| `platform.maturity_models.manage` | `platform_admin` | `superadmin` | — |
| `platform.wiki_import.execute` | `platform_admin` | `superadmin` | `wiki_import` |
| `platform.ai_prompts.manage` | `platform_admin` | `superadmin` | — |
| `platform.exercise_enrichment.execute` | `platform_admin` | `superadmin` | `ai_calls` |
| `platform.user_content.moderate` | `platform_admin` | `superadmin` | — |
| `platform.legal_documents.manage` | `platform_admin` | `superadmin` | — |
| `platform.media_storage.manage` | `platform_admin` | `superadmin` | — |
| `platform.club_creation.approve` | `platform_admin` | `superadmin` | — |
*Geplant:* `club.creation_request.submit``verified_pending_club`; Freigabe über `platform.club_creation.approve`.
---
## 6. Standard-Zuordnung Vereinsrolle → Capabilities (v1, fest)
Diese Tabelle ist die **initiale** Grant-Matrix (`club_role_capability_grants`). Später durch Custom Roles ersetzbar — gleiche Capability-IDs.
| Capability-Cluster | `club_admin` | `trainer` | `content_editor` | `division_lead` |
|--------------------|:------------:|:---------:|:----------------:|:---------------:|
| `org.structure.manage` | ✓ | — | — | ✓ (eigene Sparte, später) |
| `org.members.manage` | ✓ | — | — | — |
| `org.join_request.review` | ✓ | — | — | — |
| `exercises.read` | ✓ | ✓ | ✓ | ✓ |
| `exercises.create/update` | ✓ | ✓ | ✓ | ✓ |
| `exercises.delete` | ✓ | — | — | — |
| `exercises.ai.*` | ✓ | ✓ | ✓ | ✓ |
| `media.library.*` | ✓ | ✓ | ✓ | ✓ |
| `modules.*` / `framework.*` | ✓ | ✓ | ✓ | ✓ |
| `planning.*` | ✓ | ✓ | — | ✓ |
| `planning.coach.execute` | ✓ | ✓ | — | ✓ |
| `governance.content_report.review` | ✓ | — | — | — |
---
## 7. API-Vertrag (Ziel)
### 7.1 Effektive Rechte für Frontend
```
GET /api/me/entitlements?club_id={optional}
```
Antwort (Ausschnitt):
```json
{
"account_state": "active_member",
"portal_role": "user",
"club_id": 12,
"club_roles": ["trainer"],
"capabilities": {
"exercises.read": true,
"exercises.ai.suggest": true,
"org.members.manage": false
},
"features": {
"ai_calls": { "allowed": true, "used": 4, "limit": 50, "remaining": 46, "reset_at": "2026-07-01T00:00:00Z" }
}
}
```
Frontend: Navigation und Buttons nur aus dieser Antwort — **keine** duplizierten Rollen-Checks in JSX (Ausnahme: rein kosmetische Labels).
### 7.2 Backend-Enforcement
Zentral (Zielmodul `authorization/capabilities.py` oder Erweiterung `club_tenancy.py`):
```python
assert_capability(tenant, "exercises.ai.suggest", club_id=tenant.effective_club_id)
assert_club_feature(tenant, "ai_calls", club_id=tenant.effective_club_id) # siehe Club-Membership-Doc
# + bestehende Governance auf Objekt-Ebene
```
---
## 8. Implementierungsreihenfolge (Capabilities)
| Phase | Inhalt |
|-------|--------|
| C0 | Account-Gates (`unverified`, `verified_pending_club`) — ohne Capability-DB |
| C1 | `capabilities` + `club_role_capability_grants` seed aus §56 |
| C2 | `GET /api/me/entitlements` + Frontend-Nav |
| C3 | Enforcement: KI-Endpoints, `exercises.create`, `planning.*` |
| C4 | Restliche Router schrittweise; Audit in `ACCESS_LAYER_ENDPOINT_AUDIT.md` |
| C5 | Custom Roles (optional) — gleiche IDs |
---
## 9. Abgrenzung & Drift-Schutz
1. **Neue Nutzerfunktion**`register_capability()` in `rights_registrations/<modul>.py`, dann Endpoint mit `probe_capability`. Namenskonvention hier dokumentieren — **kein** Bulk-Seed in Migrationen.
2. **Kontingent**`register_feature()` im selben Modul; Consume über `consume_club_feature_with_usage`.
3. **Kein** paralleles `if (user.role === 'trainer')` für Sicherheit — nur UX-Fallback.
4. Capability ≠ Feature: `exercises.ai.suggest` (darf ich?) vs. `ai_calls` (wie viel übrig?).
5. Plattform-Admin-Bypass dokumentieren und auditieren (`platform_admin` sieht Mandant, nicht automatisch alle Quotas).
Siehe **`docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`** (Registry-first, ersetzt Katalog-first aus 079).
---
## 10. Referenzen
| Dokument | Inhalt |
|----------|--------|
| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Vereinsabo, Feature-Registry, Kontingente |
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | TenantContext, Governance, Stufe E |
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` | §4.6 Vereinsabo-Zielbild |
| `ACCESS_LAYER_ENDPOINT_AUDIT.md` | Endpoint-Pflege |
| Mitai `FEATURE_ENFORCEMENT.md` | 4-Phasen-Rollout-Vorbild |
---
**Changelog**
- 2026-06-06: v1 — Initial-Katalog aus Ist-Code (`club_tenancy`, Router-Inventar) + Ziel-Onboarding.

View File

@ -0,0 +1,478 @@
# Vereins-Membership & Feature-System Shinkan v1
**Status:** Konzept + M1M3 teilweise produktiv (siehe Entscheidungs-Doc §2)
**Stand:** 2026-06-06
**Bezüge:** Schwesterprojekt Mitai (`v9c_subscription_system.sql`, `FEATURE_ENFORCEMENT.md`), `CAPABILITY_CATALOG.v1.md`, `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, **`MEMBERSHIP_RBAC_DECISIONS_2026-06.md`**
---
## 1. Zweck
Shinkan verkauft und limitiert **nicht Einzelpersonen** (wie Mitai), sondern **Vereine**. Dieses Dokument definiert:
- das **Feature-Registry**-Muster (limitierbare Funktionen),
- das **Vereins-Abo** (`club_plans`, `club_subscriptions`),
- **Kontingente** und Enforcement,
- die **Abbildung von Mitai** und **Vermeidung von Refactoring-Schulden**.
Capabilities (Rollen: *darf ich die Funktion?*) → `CAPABILITY_CATALOG.v1.md`.
---
## 2. Grundprinzip: Zwei Achsen
```mermaid
flowchart TB
subgraph cap [Achse 1 — Capabilities]
CR[club_role_capability_grants]
PR[portal_role_capability_grants]
end
subgraph feat [Achse 2 — Features / Kontingente]
FP[club_plans]
FPL[club_plan_limits]
FS[club_subscriptions]
FU[club_feature_usage]
end
subgraph gov [Achse 3 — Governance]
GV[visibility / club_id / created_by]
end
REQ[HTTP Request] --> ACCT[Account-Lifecycle]
ACCT --> cap
cap --> gov
gov --> feat
feat --> EXEC[Ausführung + increment]
```
| Frage | System | Subjekt |
|-------|--------|---------|
| Darf Trainer X KI nutzen? | Capability `exercises.ai.suggest` | `profile_id` + `club_role` |
| Wie viele KI-Aufrufe hat Verein Y? | Feature `ai_calls` | **`club_id`** |
| Darf ich diese Übung ändern? | Governance | Objekt + Mitgliedschaft |
**Beide Achsen müssen erfüllt sein** (AND), außer dokumentierte Plattform-Ausnahmen.
---
## 3. Mitai-Mapping (was übernehmen, was nicht)
### 3.1 Übernehmen (Pattern)
| Mitai (Person) | Shinkan (Verein) | Anmerkung |
|----------------|------------------|-----------|
| `features` (TEXT-PK, Registry) | `features` (`app='shinkan'`) | Gemeinsames Muster, ggf. später Jinkendo-weit |
| `tiers` | `club_plans` | Produktdefinition |
| `tier_limits` | `club_plan_limits` | Matrix Plan × Feature |
| `user_feature_restrictions` | `club_feature_overrides` | Admin-Override pro Verein |
| `user_feature_usage` | `club_feature_usage` | Verbrauch pro Verein |
| `access_grants` | `club_access_grants` | Trial, Promo, manuelle Freischaltung |
| `check_feature_access()` | `check_club_feature_access()` | Subjekt `club_id` |
| `increment_feature_usage()` | `increment_club_feature_usage()` | Nur bei INSERT / KI-Call |
| 4-Phasen-Rollout | identisch | Log → UI → Hard-Block |
| `GET /api/features/usage` | `GET /api/clubs/{id}/entitlements` | siehe Capability-Doc §7 |
### 3.2 Nicht übernehmen
| Mitai | Shinkan-Grund |
|-------|---------------|
| `profiles.tier` als Haupt-Abo | Verein zahlt, nicht Einzeltrainer |
| `subscriptions` (Shinkan `001`, INT-Features) | Ungenutzt, Schema-Drift |
| `get_effective_tier(profile_id)` für Shinkan-Limits | Ersetzen durch `get_effective_club_plan(club_id)` |
| Profil-zentrierte Enforcement-Hooks allein | Primär `club_id`; Profil nur für Attribution |
### 3.3 Parallelität Jinkendo-Familie (später)
`CENTRAL_SUBSCRIPTION_SYSTEM.md` (Mitai): zentrales Personen-Abo über Apps.
**Zielbild ohne Refactoring:**
```
features.enforcement_subject ∈ { 'club', 'profile', 'portal' }
effektives_limit(feature) = merge(
club_plan_limit(club_id, feature), # Shinkan-Hauptquelle
profile_grant_limit(profile_id, feature) # optional Jinkendo-Bonus
)
```
Merge-Regel (Vorschlag): **Maximum** der erlaubten Kontingente, boolean = OR. Details vor Stripe festlegen.
---
## 4. Ist-Zustand Shinkan (Drift — zuerst bereinigen)
| Artefakt | Problem |
|----------|---------|
| `backend/migrations/001_auth_membership.sql` | `features.id SERIAL`, `tier_limits.tier VARCHAR` |
| `backend/auth.py` `check_feature_access()` | Erwartet Mitai-v9c-Schema (`features.id TEXT`, `tier_id`, `limit_type`, …) |
| Kein Router | Ruft `check_feature_access` auf |
| `profiles.tier` | Existiert, ohne Shinkan-Enforcement |
**Pflicht vor Phase 3 (Enforcement):** Migration `0XX_club_features_v1.sql` — v9c-kompatibles Feature-Schema + Vereins-Tabellen; alte `001`-Feature-Zeilen migrieren oder deprecaten.
---
## 5. Ziel-Schema (v1)
### 5.1 Feature-Registry (app-weit, Mitai-kompatibel)
```sql
-- Konzept — Implementierung als nummerierte Migration
CREATE TABLE features (
id TEXT PRIMARY KEY, -- z.B. 'ai_calls'
app TEXT NOT NULL DEFAULT 'shinkan',
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL, -- 'content'|'planning'|'ai'|'org'|'integration'|'platform'
limit_type TEXT NOT NULL DEFAULT 'count', -- 'count' | 'boolean'
reset_period TEXT NOT NULL DEFAULT 'never', -- 'never' | 'daily' | 'monthly'
default_limit INTEGER, -- NULL=∞, 0=aus
enforcement_subject TEXT NOT NULL DEFAULT 'club', -- 'club'|'profile'|'portal'
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
### 5.2 Vereins-Produkte & Abo
```sql
CREATE TABLE club_plans (
id TEXT PRIMARY KEY, -- 'free', 'verein_starter', 'verein_pro'
name TEXT NOT NULL,
description TEXT,
price_monthly_cents INTEGER,
price_yearly_cents INTEGER,
stripe_price_id_monthly TEXT,
stripe_price_id_yearly TEXT,
active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE club_subscriptions (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES club_plans(id),
status TEXT NOT NULL DEFAULT 'active', -- active|trial|past_due|cancelled
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ends_at TIMESTAMPTZ,
trial_ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (club_id) -- ein aktiver Plan pro Verein (v1)
);
CREATE TABLE club_plan_limits (
id SERIAL PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
limit_value INTEGER, -- NULL=∞, 0=deaktiviert
UNIQUE (plan_id, feature_id)
);
```
### 5.3 Overrides, Grants, Verbrauch
```sql
CREATE TABLE club_feature_overrides (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
limit_value INTEGER NOT NULL,
reason TEXT,
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (club_id, feature_id)
);
CREATE TABLE club_access_grants (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
plan_id TEXT REFERENCES club_plans(id),
feature_id TEXT REFERENCES features(id), -- optional Einzel-Feature
grant_limit INTEGER,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
reason TEXT,
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL
);
CREATE TABLE club_feature_usage (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
usage_count INTEGER NOT NULL DEFAULT 0,
reset_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
UNIQUE (club_id, feature_id)
);
-- Optional: Attribution / Fairness / Audit
CREATE TABLE club_feature_usage_events (
id BIGSERIAL PRIMARY KEY,
club_id INT NOT NULL,
feature_id TEXT NOT NULL,
profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
action TEXT NOT NULL, -- 'ai_suggest', 'exercise_create', ...
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
### 5.4 Capabilities (Rollen — Kurzreferenz)
Siehe `CAPABILITY_CATALOG.v1.md` für IDs. Tabellen:
```sql
CREATE TABLE capabilities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
domain TEXT NOT NULL,
min_account_state TEXT NOT NULL DEFAULT 'active_member',
linked_feature_id TEXT REFERENCES features(id), -- optional Kontingent
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE club_role_capability_grants (
role_code TEXT NOT NULL, -- club_admin, trainer, ...
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
PRIMARY KEY (role_code, capability_id)
);
CREATE TABLE portal_role_capability_grants (
portal_role TEXT NOT NULL, -- admin, superadmin
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
PRIMARY KEY (portal_role, capability_id)
);
```
---
## 6. Shinkan Feature-Katalog (Seed v1)
Übernahme aus `001_auth_membership.sql` + Ist-Endpoints, angereichert:
| feature_id | category | limit_type | reset_period | enforcement_subject | Default Free | Beschreibung |
|------------|----------|------------|--------------|---------------------|--------------|--------------|
| `exercises` | content | count | never | club | 100 | Anzahl Übungen im Verein (Bestand) |
| `exercise_media` | content | count | monthly | club | 20 | Medien-Uploads / Monat |
| `training_units` | planning | count | monthly | club | 40 | Geplante/durchgeführte Einheiten |
| `training_programs` | planning | count | never | club | 5 | Module + Rahmenprogramme (kombiniert v1) |
| `training_groups` | org | count | never | club | 10 | Trainingsgruppen |
| `active_members` | org | count | never | club | 25 | Aktive Mitglieder |
| `ai_calls` | ai | count | monthly | club | 0 | KI-Aufrufe (Suggest, Regenerate, Planung) |
| `ai_pipeline` | ai | boolean | never | club | 0 | Erweiterte KI-Pipelines (Batch, später) |
| `wiki_import` | integration | boolean | never | portal | 0 | MediaWiki-Import (Superadmin) |
| `data_export` | integration | boolean | never | club | 0 | Export-Funktionen (wenn eingeführt) |
**Hinweis:** Free-Defaults sind Produktentscheidung — Tabelle dient Implementierung.
### 6.1 Beispiel-Pläne (Seed)
| plan_id | ai_calls/Monat | exercises | active_members |
|---------|----------------|-----------|----------------|
| `free` | 0 | 100 | 25 |
| `verein_starter` | 30 | 500 | 80 |
| `verein_pro` | 200 | NULL (∞) | NULL |
| `pilot` | 100 | NULL | NULL |
Jeder Verein erhält bei Anlage durch Superadmin initial `club_subscriptions.plan_id = 'free'` (oder `pilot`).
---
## 7. Auflösungslogik
### 7.1 Effektiver Vereinsplan
```python
def get_effective_club_plan(cur, club_id: int) -> str:
"""
1. Aktiver club_access_grants mit plan_id (höchste Priorität, Zeitfenster)
2. club_subscriptions.status == 'active' → plan_id
3. Fallback 'free'
"""
```
### 7.2 Feature-Limit (analog Mitai `check_feature_access`)
```python
def check_club_feature_access(
cur,
club_id: int,
feature_id: str,
*,
profile_id: int | None = None, # nur für Logging / optionale Profil-Boni später
) -> dict:
"""
Priorität:
1. club_feature_overrides (club_id, feature_id)
2. club_plan_limits für get_effective_club_plan(club_id)
3. features.default_limit
Auswertung:
- limit_type boolean: limit_value == 1
- limit_type count: used < limit (club_feature_usage, reset beachten)
Returns: { allowed, limit, used, remaining, reason, reset_at }
"""
```
### 7.3 Vollständige Request-Kette
```
1. require_auth
2. assert_account_state(min_state) # unverified / verified_pending_club / active_member
3. get_tenant_context
4. assert_capability(tenant, cap_id) # Rollen-Achse
5. assert_content_governance(...) # nur bei Objekt-Endpoints
6. check_club_feature_access(club_id, feature_id)
7. … Business-Logik …
8. consume_club_feature_with_usage(…) + merge_feature_usage_into_response(payload, usage)
# Standard: zählen, JSON-Log phase=consume, feature_usage in Response
9. optional: club_feature_usage_events (profile_id, action)
```
**Response-Standard (alle Consume-Endpoints):** JSON-Feld `feature_usage` — Map `feature_id → { allowed, used, limit, remaining, reason, … }` wie `GET /me/entitlements`. Frontend: `request()` synchronisiert Entitlements automatisch (`featureUsageSync.js`); UI-Komponenten brauchen keinen Einzelcode.
### 7.4 Wer zählt als Verbrauch?
| Aktion | increment | Subjekt |
|--------|-----------|---------|
| `POST /exercises` (neu) | `exercises` | `club_id` des Objekts oder `effective_club_id` |
| Medien-Upload | `exercise_media` | Verein des Mediums |
| KI Suggest/Regenerate | `ai_calls` | `effective_club_id` |
| Mitglied hinzufügen | `active_members` | Ziel-`club_id` |
| Trainingsgruppe anlegen | `training_groups` | `club_id` |
**Mitai-Regel:** Counter **nicht** bei UPDATE/DELETE erhöhen.
---
## 8. API-Oberfläche
### 8.1 Nutzer / Vereinsadmin
```
GET /api/clubs/{club_id}/entitlements
```
Kombiniert Capabilities + Feature-Kontingente (siehe `CAPABILITY_CATALOG.v1.md` §7.1).
```
GET /api/me/entitlements?club_id=12
```
Bequemer Alias für aktiven Verein.
### 8.2 Superadmin / Plattform
| Endpoint | Zweck |
|----------|-------|
| `GET/PUT /api/admin/club-plans` | Plan-CRUD |
| `GET/PUT /api/admin/club-plan-limits` | Matrix |
| `GET/PUT /api/admin/clubs/{id}/subscription` | Verein-Abo |
| `GET/PUT /api/admin/clubs/{id}/feature-overrides` | Sonderkontingente |
| `POST /api/admin/clubs/{id}/access-grants` | Trial/Promo |
Vorbild UI: Mitai `AdminTierLimitsPage.jsx`, `AdminUserRestrictionsPage.jsx` → Vereins-Kontext.
### 8.3 Geplant: Vereinsgründung
```
POST /api/club-creation-requests # Nutzer (verified_pending_club)
GET /api/admin/club-creation-requests
POST /api/admin/club-creation-requests/{id}/approve # legt club + subscription an
```
---
## 9. Vier-Phasen-Rollout (aus Mitai)
| Phase | Shinkan-Aktivität | Nutzer sichtbar? |
|-------|-------------------|------------------|
| **0** | Schema-Migration, Seed `features` + `club_plans`, Drift `001` bereinigen | Nein |
| **1** | Account-Gates + Capability-Grants (ohne Limits) | Onboarding-Hinweise |
| **2** | `check_club_feature_access`**nur JSON-Log** (`feature_logger` analog Mitai) | Nein |
| **3** | `GET …/entitlements` + UsageBadge im UI | Ja (Kontingent-Anzeige) |
| **4** | HTTP 403 bei Limit + `increment` | Ja (Hard-Block) |
**Reihenfolge innerhalb Phase 4:** zuerst `ai_calls`, dann `exercise_media`, dann Bestands-Limits (`exercises`, `active_members`).
---
## 10. CI / Test-Isolation (Betrieb)
Unabhängig vom Membership-System — **Pflicht** wegen Prod-Vorfälle (`access_layer_it_*@test.local`):
| Regel | Umsetzung |
|-------|-----------|
| Integrationstests nie gegen Prod-DB | Eigene Test-DB oder Job-Postgres in Gitea |
| `ENVIRONMENT=production` + `ALLOW_INTEGRATION_TESTS` | Default `0`, Tests abbrechen |
| Test-Accounts | E-Mail `@test.local` oder `profiles.is_test_account` |
| Cleanup | Fixture-`finally` + Nightly-Job löscht Leichen |
`.gitea/workflows/test.yml`: pytest-backend gegen Deploy-DB **ersetzen** durch isolierte DB (eigenes Epic, parallel zu Membership).
---
## 11. Implementierungs-Roadmap (gesamt)
| Schritt | Deliverable | Membership-relevant |
|---------|-------------|-------------------|
| M0 | CI-Isolation + Prod-Cleanup-Runbook | Nein |
| M1 | Migration Feature-Schema v9c + `club_plans`/`club_subscriptions` (leer nutzbar) | **Ja** |
| M2 | `check_club_feature_access` + Seed Pläne | **Ja** |
| M3 | Account-Lifecycle + Capability-Grants | Capabilities |
| M4 | `GET /me/entitlements` | **Ja** |
| M5 | Enforcement `ai_calls` (Phase 4) | **Ja** |
| M6 | Admin Plan-Matrix UI | **Ja** |
| M7 | `club_creation_requests` | Prozess |
| M8 | Stripe / Rechnung | Später |
**Nach Produktentscheidungen 2026-06-06** (Details `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §4):
| Phase | Paket | Priorität |
|-------|--------|-----------|
| A | Onboarding-Gates vollständig (`verified_pending_club`) | **Als Nächstes** |
| B | M7 Vereinsgründung beantragen | hoch |
| C | M5 Hard-Block `ai_calls` | danach |
| D | M6 Superadmin-UI | danach |
| E | Systemrolle `co_trainer` + Frontend-Entitlements | v1 Rollen |
| F | Trainer-Member-Budgets (v2) | später |
---
## 12. Offene Produktentscheidungen
Vor M6 festlegen:
1. **Zählen `active_members`:** alle Mitglieder oder nur Rollen mit Planungsrecht?
2. **Soft-Limit vs. Hard-Stop:** Warnung bei 80 % oder sofort 403?
3. **Pilotverein:** eigener Plan `pilot` mit hohen Limits?
4. **KI-Fairness:** nur Vereinslimit oder zusätzlich Max pro Trainer/Monat?
5. **Offizielle Inhalte:** für `verified_pending_club` sichtbar oder gesperrt? → **entschieden: gesperrt** (`MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.1)
6. **Portal `admin` vs. `superadmin`:** Wer darf Vereine anlegen? (Ziel: nur `superadmin` für Freigabe)
---
## 13. Referenzen
| Pfad | Inhalt |
|------|--------|
| `c:/dev/mitai-jinkendo/backend/migrations/v9c_subscription_system.sql` | Mitai-Schema-Vorlage |
| `c:/dev/mitai-jinkendo/.claude/docs/architecture/FEATURE_ENFORCEMENT.md` | 4-Phasen-Modell |
| `c:/dev/mitai-jinkendo/.claude/docs/technical/MEMBERSHIP_SYSTEM.md` | Mitai-Hauptdoku |
| `c:/dev/mitai-jinkendo/.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md` | Jinkendo-Familie später |
| `CAPABILITY_CATALOG.v1.md` | Rollen & Capabilities |
| `MULTI_TENANCY_RBAC_ARCHITECTURE.md` §4.6 | Ursprüngliches Vereinsabo-Zielbild |
| `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Stufe E/F |
---
**Changelog**
- 2026-06-06: v1 — Mitai-Mapping, Ziel-Schema, Feature-Seed, Auflösungslogik, Rollout.

View File

@ -0,0 +1,243 @@
# Membership, RBAC & Kontingente — Produktentscheidungen
**Status:** Verbindlich (Zielbild & Roadmap-Priorisierung)
**Stand:** 2026-06-06
**Bezüge:** `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`, `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
Dieses Dokument hält **getroffene Produktentscheidungen** fest (Session 2026-06-06) und ergänzt die v1-Konzept-Specs um Umsetzungsrichtung. Technischer Implementierungsstand: Abschnitt 2.
---
## 1. Getroffene Entscheidungen
### 1.1 Onboarding: `verified_pending_club`
Nutzer **ohne aktive Vereinsmitgliedschaft** (E-Mail verifiziert) dürfen **nur**:
| Erlaubt | Nicht erlaubt (Zielbild) |
|---------|---------------------------|
| Konto / Einstellungen | Übungen, Planung, KI, Medien |
| Vereinsverzeichnis lesen | Vereinsinterne Inhalte (`club`), private Fremdinhalte |
| **Beitrittsantrag** an bestehenden Verein | Vollzugriff auf Bibliothek / offizielle Inhalte (Lesen) — **bewusst gesperrt** bis Mitgliedschaft |
| **Vereinsgründung beantragen** (Prozess M7, Superadmin-Freigabe) | |
**Kein** „Bibliothek durchstöbern“ für Bewerber — reduziert Datenexposition und vereinfacht UX („erst Verein, dann Arbeit“).
Technischer Zustand: `account_state = verified_pending_club` (siehe `CAPABILITY_CATALOG.v1.md` §3).
---
### 1.2 Rollenmodell: Risikoarm statt Big-Bang
**Zielbild (langfristig):**
- **Fest:** nur `superadmin` (Plattform) als nicht konfigurierbare Systemrolle.
- **Dynamisch konfigurierbar:** alle Vereinsrollen und deren Capability-Bundles (später `club_custom_roles`).
- Optional: `admin` (Plattform) als abgeschwächter Portal-Admin bleibt vorerst bestehen (Ist-Code).
**Entscheidung v1 (risikoarm):**
| Maßnahme | Jetzt | Später |
|----------|-------|--------|
| Alte Helfer (`can_plan_in_club`, `if (club_admin)` in JSX) | **Behalten** — weiter produktiv | Schrittweise durch `entitlements` ersetzen |
| Neue Endpoints / Features | Nur über **Capability-IDs** + Audit | — |
| Neue Vereinsrollen | Als **Systemrollen** ergänzen (z.B. `co_trainer`) | Custom Roles UI |
| `club_custom_roles` | **Nicht** in v1 | v2 Epic |
**Begründung:** Backend und Frontend haben hunderte Verdrahtungen auf `trainer` / `club_admin` / Plattform-Rollen. Parallelbetrieb Capability-System + Legacy-Helfer ist sicherer als einmaliges Aufbrechen.
**Co-Trainer (geplant als Systemrolle):** weniger Capabilities als `trainer` (z.B. kein `planning.*`, kein `exercises.create`) — Umsetzung nach Onboarding-Gates + Entitlements-Rollout, nicht vorher.
---
### 1.3 Vereins-Kontingente (Membership-Pakete)
**Jetzt:** Schema und Anzeige vorbereiten; **keine** detaillierte Paket-Logik (z.B. „3 Trainer + 10 Co-Trainer“) implementieren.
| Vorbereitet (DB/Module) | Bewusst zurückgestellt |
|-------------------------|-------------------------|
| `features`, `club_plans`, `club_subscriptions` | Eigene Feature-IDs `trainer_seats` / `co_trainer_seats` |
| Bestands-Limits (`exercises`, `training_groups`, `ai_calls`, …) | Zählregel „nur planungsberechtigte Mitglieder“ vs. alle Mitglieder |
| `GET /me/entitlements` Feature-Teil | Stripe / Rechnung (M8) |
**Prinzip:** Neue Kontingent-Typen = neue `features`-Zeile + Plan-Limits + optional Capability-`linked_feature_id` — ohne Schema-Bruch.
---
### 1.4 Trainer-Budget innerhalb Vereins-Kontingent (v2)
**Anforderung:** Vereins-KI-Kontingent liegt beim Verein; **Vereinsadmin** kann pro Trainer ein **Sub-Budget** vergeben (Fairness, „Kontingent-Fresser“).
**Entscheidung:**
- v1: nur **Vereins-Ebene** (`club_plan_limits`, `club_feature_usage`).
- v2: neue Tabellen (Skizze):
```sql
-- Skizze — noch nicht migriert
club_member_feature_budgets (club_id, profile_id, feature_id, limit_value, …)
club_member_feature_usage (club_id, profile_id, feature_id, usage_count, reset_at, …)
```
**Prüf-Kette v2:** Capability → Mitglieds-Budget (falls gesetzt, `profile_id` aus Session) → Vereins-Kontingent.
**Fairness-Modell (offen, Tendenz):** harte Sub-Budgets (Modell A) — Trainer darf sein Budget nicht überschreiten, auch wenn Verein noch Rest hat.
**Roadmap:** Phase 5b / Meilenstein **M9** in `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` — Vereinsadmin-UI zur Verteilung, Entitlements mit persönlichem + Vereins-Rest, Auswertung je Person.
---
### 1.5 Enforcement-Phasen (unverändert, bestätigt)
| Phase | Verhalten | Nutzer sichtbar |
|-------|-----------|-----------------|
| 2 (M2/M3) | JSON-Log, kein Block | Nein (außer Logs) |
| 3 (M4) | `GET /me/entitlements` + Badge | Kontingent-Anzeige |
| 4 (M5+) | HTTP 403 + `increment` | Hard-Block |
Env-Schalter: `ACCOUNT_GATE_ENFORCE` (Default `1`, Endpoint-Helfer), `ACCOUNT_GATE_API_ENFORCE` (Default `1`, API-Middleware Phase A), `CAPABILITY_ENFORCE` / `CLUB_FEATURE_ENFORCE` (Default `0`).
---
## 2. Implementierungsstand (Ist, Codebase)
**DB-Schema:** `20260606083` · App **0.8.199** (`backend/version.py`)
**Roadmap (detailliert):** `docs/working/RBAC_ENFORCEMENT_ROADMAP.md`
### M1 — Feature-Schema v9c ✅
| Deliverable | Status |
|-------------|--------|
| Migration `078_club_features_and_plans.sql` | ✅ |
| Legacy `001` archiviert | ✅ |
| `club_plans`, `club_subscriptions`, Usage-Tabellen | ✅ |
| Seed Features + Pläne (`free`, …) | ✅ |
| `club_features.py`: `check_club_feature_access`, `get_effective_club_plan` | ✅ |
| Backfill Vereine → Plan `free` | ✅ |
### M2 — Feature-Probe (Log only) ✅
| Deliverable | Status |
|-------------|--------|
| `club_feature_logger.py``club-feature-usage.log` | ✅ |
| `probe_club_feature_access()` | ✅ |
| Hooks: KI-Endpoints, `POST /exercises`, Medien-Upload, Planungs-KI | ✅ |
| Consume-Standard + `feature_usage` in Response (`ai_calls`) | ✅ |
| `CLUB_FEATURE_ENFORCE=0` (Default) | ✅ |
### M3 — Account-Lifecycle + Capability-Grants ⚠️ teilweise
| Deliverable | Status | Lücke |
|-------------|--------|-------|
| Migration `079_capabilities.sql` + Seed | ✅ | — |
| `account_lifecycle.py`, `resolve_account_state` | ✅ | — |
| `capabilities.py`, `check_capability`, `probe_capability` | ✅ | — |
| `TenantContext.account_state` | ✅ | — |
| `GET /profiles/me``account_state`, `club_roles` | ✅ | — |
| Account-Gates auf **Schreib-/KI-Endpoints** | ✅ | Lesepfade für Bewerber noch offen |
| `CAPABILITY_ENFORCE=0` (nur Log) | ✅ | — |
| Onboarding UX: nur Bewerbung/Gründung | ✅ | Phase A: API-Middleware + `/onboarding` + reduzierte Nav |
| `club_creation_requests` (M7) | ✅ Basis | Capabilities + Admin-Freigabe |
| Quota-Bypass via Capability-Grants (083) | ✅ | kein paralleles Exemption-Schema |
| Custom Roles / Co-Trainer | ❌ | bewusst v2 |
| Legacy-Helfer entfernt | ❌ | bewusst parallel |
### M4 — Anzeige ✅ teilweise
| Deliverable | Status |
|-------------|--------|
| `GET /api/me/entitlements` | ✅ |
| `EntitlementsContext`, `hasCapability()` | ✅ (UI nutzt noch kaum) |
| `FeatureUsageBadge` | ✅ nur KI im Übungsformular |
| `featureUsageSync` in `request()` | ✅ |
### M5 — Hard-Block + vollständiger Verbrauch ⚠️
| Deliverable | Status |
|-------------|--------|
| `consume_club_feature_with_usage` Standard | ✅ `ai_calls` |
| `CLUB_FEATURE_ENFORCE=1` produktiv | ❌ Default 0 |
| Consume `exercises`, `exercise_media`, … | ❌ |
### M6 — Admin UI Rollen & Rechte ⚠️
| Deliverable | Status |
|-------------|--------|
| `/admin/rights` Capability-Matrix (Portal + Verein) | ✅ |
| Klartext zuerst, Enforcement-Badge | ✅ 2026-06-07 |
| Kontingent-Bypass + Vereinspläne (Seed) | ✅ |
| Neue Pläne / Rollen anlegen (CRUD) | ❌ |
### Bewusst zurückgestellt
| ID | Inhalt |
|----|--------|
| M0 | CI-Isolation / Test-DB |
| M8 | Stripe |
| v2 | Trainer-Budgets, Custom Roles |
---
## 3. Architektur-Zielbild (kompakt)
```
Request
→ require_auth
→ account_state (Gate)
→ TenantContext
→ assert_capability (Rolle / Funktion)
→ check_club_feature_access (Vereins-Kontingent)
→ [v2] member_feature_budget (Trainer-Budget)
→ Governance (Objekt)
```
**Drei Achsen:** Account-Lifecycle · Capabilities · Features (Kontingente). Governance bleibt vierte Prüfung.
---
## 4. Empfohlene Roadmap (nach Entscheidungen)
| Phase | Paket | Warum zuerst |
|-------|--------|--------------|
| **A** | **Onboarding-Gates vollständig** | ✅ umgesetzt (API + Frontend `/onboarding`) |
| **B** | **M7 Vereinsgründung beantragen** | **Als Nächstes** — zweiter Pfad für `verified_pending_club` |
| **C** | **M5 Hard-Block `ai_calls`** | Free-Plan `0` wird real; Badge (M4) liefert Erklärung |
| **D** | **M6 voll** | Pläne-CRUD, Rollen-CRUD | ⚠️ Matrix da |
| **E** | Entitlements im Frontend (`hasCapability`) | Entscheidung 1.2 risikoarm |
| **F** | **M9 Kontingent-Verteilung** — Vereinsadmin vergibt Sub-Budgets pro Person (`profile_id`); Prüfung + Consume personenbezogen; UI Vereinsorga | Entscheidung 1.4, Roadmap Phase 5b |
| **G** | `co_trainer` + Custom Roles (v2) | Entscheidung 1.2 |
M0 parallel, nicht blockierend.
---
## 5. Offene Punkte (vor M6 / v2)
1. Fairness Modell A/B/C für Trainer-Budget (Tendenz: A).
2. Ob `admin` (Portal) langfristig neben `superadmin` bleibt.
3. Ob offizielle Inhalte für Bewerber **nie** lesbar bleiben (aktuell: ja).
---
## 6. Referenzen
| Pfad | Inhalt |
|------|--------|
| `CAPABILITY_CATALOG.v1.md` | Capability-IDs, Account-States |
| `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Feature-Registry, Kontingente |
| `backend/club_features.py` | Vereins-Features |
| `backend/capabilities.py` | Capability-Auflösung |
| `backend/account_lifecycle.py` | Account-Gates |
## 7. Superadmin im Verein (FAQ)
Siehe **`docs/working/RBAC_ENFORCEMENT_ROADMAP.md` §4**: Plattform-Admin (`admin`, `superadmin`) erhält **Capability-Bypass** für Vereins-Funktionen ohne `club_admin`-Mitgliedschaft. Mandant über aktiven Verein wählen; Kontingente via Bypass. Einzelne Legacy-Pfade (z.B. Löschen `visibility=club`) sind noch nicht vereinheitlicht — Ziel Phase 3.
---
**Changelog**
- 2026-06-06: Initial — Entscheidungen Onboarding, Rollen-Risiko, Kontingente, Trainer-Budget v2; Ist-Stand M1M3; Roadmap AF.
- 2026-06-06: Phase A — `account_onboarding_gate.py`, Frontend `/onboarding`, reduzierte Navigation.
- 2026-06-07: M4M6 Ist-Stand, Roadmap-Verweis, Superadmin-FAQ; Admin-Matrix UX + Enforcement-Audit.
- 2026-06-08: Roadmap Phase 5b / M9 — Vereinsadmin-Kontingentverteilung pro Person; Enforce Dev verifiziert (0.8.202).

View File

@ -227,7 +227,9 @@ Ziel: **vereinszentrierte** Vertrags- und Limitlogik, analog zur bestehenden Tie
## 8. Verwandtes Dokument
- **`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`** verbindliche Umsetzungsstufen AF, einheitliche Zugriffsschicht, Scope-Erweiterung (`division`, später Community), Capability-Vorbereitung ohne Custom-Rollen-UI; Vereinsabo explizit zurückgestellt.
- **`CAPABILITY_CATALOG.v1.md`** Rollen, Capability-IDs, Account-Lifecycle, Endpoint-Mapping.
- **`CLUB_MEMBERSHIP_AND_FEATURES.v1.md`** Vereinsabo, Feature-Registry (Mitai-v9c-Pattern), Kontingente.
---
**Letzte Aktualisierung:** 2026-05-05
**Letzte Aktualisierung:** 2026-06-06

View File

@ -5,6 +5,8 @@
**Status:** Planungs-/Architektur-Arbeitspapier (keine Implementierungspflicht)
**Ziel:** Für die **spätere** Planungs-KI bereits **Schnittstellen und Schichten** vorzeichnen, damit die **kleinere, starre** Übungs-KI nicht zur impliziten Vorlage für einen viel größeren Kopf wird — **ohne** jetzt eine Mitai-artige Workflow-Engine zu bauen.
**Update 2026-06-07:** Progressionsgraph startet **Phase F** (`planning_progression_roadmap.py`) — Roadmap-first, Workflow-lite. Siehe **`PLANNING_PROGRESSION_ROADMAP_SPEC.md`** und **`docs/architecture/PLANNING_KI_ROADMAP.md`**. Gruppenanalyse bleibt in der **Trainingsplanungs-Pipeline** (§3 S0S4), nicht im Graphen.
**Bezüge:** `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/SKILL_SCORING_SPEC.md` · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-003) · Schwesterprojekt Mitai: `c:/dev/mitai-jinkendo` (Referenz: `prompt_executor`, `placeholder_resolver`, `workflow_*`**nicht** Pflicht-Port).
---
@ -107,6 +109,16 @@ So bleibt dieselbe fachliche Tiefe erreichbar ohne Kontext-Explosion.
---
## 9. Changelog
## 9. Progressionsgraph vs. Trainingsplanung (2026-06-07)
| Pipeline | Kontext | Orchestrator |
|----------|---------|--------------|
| **Progressionsgraph (F)** | Zieltext, N Steps, Semantic Brief | `planning_progression_roadmap.py` |
| **Trainingsplanung (G, später)** | Gruppe, Historie, Rahmen, Zeit | `planning_ai_steps` + ggf. Mitai Workflow |
---
## 10. Changelog
- **2026-06-07:** Verweis Phase F Roadmap-first; Abgrenzung Graphen/Planung.
- **2026-05-22:** Erstfassung als Vorschau-Dokument für mehrstufige Planungs-KI.

View File

@ -172,7 +172,7 @@ score = w_ft * fulltext_rank
Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
- Gleiches `context_summary` an `suggestExerciseAi` anhängen (Felder `planning_context_json` o. ä. — noch offen)
- `planning_context` im Request-Body → `planning_context_json` in Übungs-Prompts (Migration **085**); Pfad-Builder + Picker ✅ **0.8.208**
- Kurzbeschreibung optional leer (freier Vorschlag) oder aus Intent/Skizze
---
@ -193,7 +193,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** |
| **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** |
| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** |
| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
| **D** | Neu-Anlage: `planning_context` an `suggestExerciseAi` (Migration **085**) | ✅ **0.8.208** |
---
@ -486,4 +486,30 @@ Nach Pfad-Bildung:
---
## 23. Backlog (offen)
## 23. Phase E3 (0.8.203) ✅
- Off-Topic aus Pfad entfernen; `gap_fill_offers` mit `goal_for_ai`; voller KI-Call im UI (kein Pre-Vorschlag)
- Migration **077** `suggested_new_exercises` im Pfad-QS-Prompt
---
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204+) 🔄
**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung.
**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md`
| Teil | Modul / API |
|------|-------------|
| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) |
| API | `include_roadmap_preview`, `include_llm_roadmap`, `roadmap_first` auf `progression-path-suggest` |
| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code |
| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) |
**F3 (0.8.206):** `roadmap_first=true` (Default im UI) — Retrieval pro `stage_spec`/Major Step; `roadmap_unfilled` Gap-Angebote. Ohne Flag: retrieval-first wie bisher, Roadmap nur Preview.
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
---
## 25. Backlog (offen)

View File

@ -0,0 +1,198 @@
# Planungs-KI — Progressions-Roadmap (Phase F)
**Version:** 0.1
**Datum:** 2026-06-07
**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+)
**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse
**Bezüge:**
`working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md`
---
## 1. Entscheidung (2026-06-07)
### 1.1 Problem
Der Pfad-Builder (Phase C3/E) ist **retrieval-first**: Zieltext → N Übungen aus der Bibliothek → QS nachbessern. Das entspricht nicht der menschlichen Planung (Ziel → Roadmap → Stufenspezifikation → Übung).
### 1.2 Festlegung
| Thema | Entscheidung |
|--------|----------------|
| **Progressionsgraph** | **Roadmap-first** — Phasen A→B→C, dann Bibliothek (D), dann Feinausplanung (E) |
| **Gruppenanalyse** | **Nicht** in der Graphen-Pipeline — erst bei **Trainingsplanung** (Einheit/Rahmen) |
| **Mitai Workflow-Engine** | **Nicht** jetzt portieren — **Workflow-lite** (`PlanningProgressionPipeline`), später workflow-ready |
| **Ein Mega-Prompt** | **Verboten** — validierte Artefakte pro Phase |
### 1.3 Abgrenzung Trainingsplanung
```
Progressionsgraph-Pipeline Trainingsplanungs-Pipeline (später)
───────────────────────── ───────────────────────────────────
Ziel + N Major Steps Gruppe + Historie + Termin + Rahmen
Kein Gruppenkontext Kontext-Pack S0 (AI_PLANNING_KI_MULTISTAGE_FORECAST)
Curriculum / Technikpfad Session-Füllung / Reihenfolge / Zeiten
```
---
## 2. Menschliches Vorbild → Phasen
| Mensch | Phase | Output-Artefakt | LLM |
|--------|-------|-----------------|-----|
| Startpunkt + Zielzustand | **A** Zielanalyse | `goal_analysis` | Optional (klein) |
| Zwischenziele, gewichten, auf N reduzieren | **B** Roadmap | `roadmap` (`micro_objectives[]`, `major_steps[N]`) | Ja |
| Belastung, Übungstyp, Lernziel je Stufe | **C** Stufenspezifikation | `stage_specs[]` | Teilweise |
| Bibliothek / Brücke | **D** Match | `step_matches[]` oder `gaps[]` | Nein (Retrieval) |
| Skizze + Feinplan | **E** Übungsentwurf | bestehend `suggestExerciseAi` | On-demand |
**Phase B** = Kern: 812 `micro_objectives` → Konsolidierung → exakt `max_steps` `major_steps`.
---
## 3. Pipeline-Orchestrator (Workflow-lite)
Modul: **`backend/planning_progression_roadmap.py`**
```python
ctx = ProgressionRoadmapContext(goal_query=..., max_steps=N, semantic_brief=...)
ctx = phase_a_goal_analysis(ctx) # deterministisch + optional LLM
ctx = phase_b_roadmap(ctx) # micro → major
ctx = phase_c_stage_specs(ctx) # je major_step
# Phase D/E: bestehende path_builder / retrieval / ai_fill — speisen von ctx.major_steps
```
Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-the-loop** (Roadmap-Review vor Übungs-Match).
**Später:** jede Phase = Workflow-Knoten (Mitai-kompatibel), keine API-Änderung an Artefakten.
---
## 4. JSON-Artefakte (Pydantic)
### 4.1 `goal_analysis` (Phase A)
```json
{
"primary_topic": "Mae Geri",
"start_assumption": "Grundkenntnisse der Standführung, keine Perfektion",
"target_state": "Sicherer, präziser Mae Geri unter Belastung und in Anwendung",
"success_criteria": ["saubere Kammerhaltung", "Hüftführung", "Kime am Zielpunkt"],
"constraints": { "partner_required": false, "equipment": [] }
}
```
### 4.2 `roadmap` (Phase B)
```json
{
"micro_objectives": [
{ "id": "m1", "phase": "grundlage", "title": "Stellung und Kammerhaltung", "weight": 0.9, "depends_on": [] },
{ "id": "m2", "phase": "vertiefung", "title": "Hüft- und Kniekoordination", "weight": 0.85, "depends_on": ["m1"] }
],
"major_steps": [
{
"index": 0,
"phase": "grundlage",
"learning_goal": "Stabile Mae-Geri-Grundstellung",
"consolidates": ["m1"],
"rationale": "Einstieg ohne Perfektionsdruck"
}
],
"consolidation_notes": ["Perfektion mit Anwendung zusammengeführt"]
}
```
### 4.3 `stage_spec` (Phase C, je Major Step)
```json
{
"major_step_index": 2,
"learning_goal": "…",
"load_profile": ["präzision", "koordination"],
"exercise_type": "kihon_einzel",
"success_criteria": ["…"],
"anti_patterns": ["reine Kraftübung ohne Technikbezug"]
}
```
---
## 5. API (schrittweise)
### 5.1 Erweiterung `POST /api/planning/progression-path-suggest`
| Feld (neu) | Default | Bedeutung |
|------------|---------|-----------|
| `roadmap_first` | `false` → später `true` | Roadmap-Pipeline vor Retrieval |
| `include_roadmap_preview` | `true` wenn `roadmap_first` | Artefakte A/B/C in Response |
**Response (neu):**
```json
{
"progression_roadmap": {
"goal_analysis": { },
"roadmap": { },
"stage_specs": [ ],
"pipeline_phase": "roadmap_v1"
},
"steps": [ ]
}
```
**Übergangsphase (0.8.204):** `include_roadmap_preview=true` liefert Roadmap **parallel** zum bestehenden retrieval-first Pfad — UI kann Roadmap reviewen, Schritte bleiben vorerst retrieval-basiert.
**Zielphase (F2):** `roadmap_first=true` — Retrieval pro Major Step aus `stage_specs`, nicht mehr iterativ „beste nächste Übung“.
### 5.2 Prompt-Slugs — nur in `ai_prompts`, nie im Code
**Regel:** Prompt-**Texte** leben ausschließlich in der Tabelle `ai_prompts` (Superadmin bearbeitbar, Vorschau, `openrouter_model` pro Zeile). Python referenziert nur **Slugs** (`PROMPT_SLUG_*` in `planning_progression_roadmap.py`). Kein verstecktes Hardcoding von Templates.
| Slug | Phase | Migration |
|------|-------|-----------|
| `planning_progression_goal_analysis` | A | **078** |
| `planning_progression_roadmap` | B | **078** |
| `planning_progression_stage_spec` | C | **079** |
**API:** `include_llm_roadmap` (Default `true`) — lädt Prompts via `load_and_render_ai_prompt`. Bei Fehler/kein OpenRouter: **deterministischer Fallback** (kein stilles Versagen).
**Response:** `prompt_slugs` (genutzte Slugs), `prompt_slug_catalog` (Referenz), `llm_*_applied` Flags.
**Admin:** Templates unter Kategorie `training` pflegen — siehe `AI_PROMPT_SYSTEM_SPEC.md`.
---
## 6. UI-Roadmap
1. **F1:** Roadmap-Box unter Ziel-Eingabe (Major Steps als Karten, editierbar) — vor Übungsliste
2. **F2:** Match-Ergebnis pro Major Step (Bibliothek / Lücke / KI anlegen)
3. **F3:** `roadmap_first` als Default im Graph-Builder
---
## 7. Was bewusst nicht in Phase F
- Gruppen-Historie, Belastungssteuerung der Gruppe
- Mitai `workflow_engine` Port
- Vollautomatisches Speichern ohne Trainer-Review
---
## 8. Implementierungsstände
| ID | Inhalt | Status |
|----|--------|--------|
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | 🔄 0.8.204 |
| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 |
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 |
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 |
| **F4** | UI Roadmap-Review | ✅ 0.8.207 |
| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
---
## 9. Changelog
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.

View File

@ -34,6 +34,10 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_MODEL=anthropic/claude-sonnet-4
# Vereins-Kontingente hart blockieren (KI-Kosten!). Nur 1, true oder yes aktivieren.
# Nach Änderung: docker compose -f docker-compose.dev-env.yml up -d backend
CLUB_FEATURE_ENFORCE=1
# Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).

View File

@ -18,6 +18,11 @@ jobs:
docker compose -f docker-compose.dev-env.yml build --no-cache
docker compose -f docker-compose.dev-env.yml up -d
sleep 5
curl -sf http://localhost:8098/api/version && echo "✓ DEV API healthy"
if ! curl -sf http://localhost:8098/api/version; then
echo "✗ DEV API nicht erreichbar — Backend-Logs (Migration/Startup):"
docker compose -f docker-compose.dev-env.yml logs backend --tail 120 || true
exit 1
fi
echo "✓ DEV API healthy"
curl -sf http://localhost:3098/api/version && echo "✓ DEV über Frontend-Nginx (wie Browser) healthy"
echo "=== Shinkan DEV Deploy complete ==="

View File

@ -1,10 +1,13 @@
name: Test Suite
# develop: push/PR → Tests gegen Dev (parallel oder vor Deploy Development).
# main: kein push/PR-Trigger — vermeidet doppelten Dev-Lauf beim Merge develop→main;
# Prod-Tests nur via workflow_run nach erfolgreichem Deploy Production.
on:
push:
branches: [main, develop]
branches: [develop]
pull_request:
branches: [main, develop]
branches: [develop]
workflow_run:
workflows: ["Deploy Development", "Deploy Production"]
types: [completed]
@ -17,8 +20,10 @@ jobs:
steps:
- name: Backend pytest im deployten Container
run: |
set -e
EVENT_NAME="${{ github.event_name }}"
REF_NAME="${{ github.ref_name }}"
BASE_REF="${{ github.base_ref }}"
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
APP_DIR="/home/lars/docker/shinkan"
COMPOSE_FILE="docker-compose.yml"
@ -28,12 +33,27 @@ jobs:
APP_DIR="/home/lars/docker/shinkan-dev"
COMPOSE_FILE="docker-compose.dev-env.yml"
fi
elif [ "$REF_NAME" = "develop" ]; then
elif [ "$REF_NAME" = "develop" ] || [ "$BASE_REF" = "develop" ]; then
APP_DIR="/home/lars/docker/shinkan-dev"
COMPOSE_FILE="docker-compose.dev-env.yml"
fi
cd "$APP_DIR"
echo "Warte auf stabilen backend-Container …"
for i in $(seq 1 60); do
if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then
echo "Backend bereit (Versuch $i)"
break
fi
if [ "$i" -eq 60 ]; then
echo "Timeout: backend-Container nicht bereit"
docker compose -f "$COMPOSE_FILE" ps || true
docker compose -f "$COMPOSE_FILE" logs backend --tail 80 || true
exit 1
fi
sleep 5
done
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
pip install -r /app/requirements-dev.txt &&
cd /app &&

View File

@ -18,6 +18,7 @@
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
> | Planungs-KI Progressions-Roadmap (Phase F) | **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
## Projekt-Übersicht

View File

@ -0,0 +1,77 @@
"""
Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0).
Zustände: unverified verified_pending_club active_member; platform_admin separat.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING, Optional
from fastapi import HTTPException
from club_tenancy import is_platform_admin
if TYPE_CHECKING:
from tenant_context import TenantContext
_ACCOUNT_STATE_RANK = {
"unverified": 1,
"verified_pending_club": 2,
"active_member": 3,
"platform_admin": 4,
}
def resolve_account_state(
*,
email_verified: bool,
global_role: str,
has_active_membership: bool,
) -> str:
"""Ermittelt account_state für ein Profil."""
if is_platform_admin(global_role):
return "platform_admin"
if not email_verified:
return "unverified"
if not has_active_membership:
return "verified_pending_club"
return "active_member"
def account_state_satisfies(current: str, required: str) -> bool:
"""True wenn current mindestens required ist."""
cur = _ACCOUNT_STATE_RANK.get(current, 0)
req = _ACCOUNT_STATE_RANK.get(required, 99)
if current == "platform_admin":
return True
return cur >= req
def account_gate_enforcement_enabled() -> bool:
"""Account-Gates aktiv (Default an — nur wenige Endpoints in M3)."""
return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1"
def assert_min_account_state(
tenant: "TenantContext",
min_state: str,
*,
endpoint: Optional[str] = None,
) -> None:
"""
Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default).
"""
current = getattr(tenant, "account_state", "active_member")
ok = account_state_satisfies(current, min_state)
if ok:
return
if not account_gate_enforcement_enabled():
return
detail = (
f"Account-Status „{current}“ reicht nicht für diese Aktion "
f"(erforderlich: {min_state})."
)
if endpoint:
detail = f"{detail} ({endpoint})"
raise HTTPException(status_code=403, detail=detail)

View File

@ -0,0 +1,178 @@
"""
API-Gates für Onboarding (Phase A MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1).
Blockiert Domänen-APIs für unverified / verified_pending_club vor dem Router.
"""
from __future__ import annotations
import os
import re
from typing import Optional, Tuple
from account_lifecycle import resolve_account_state
from club_tenancy import memberships_with_roles
# Öffentlich ohne Session
PUBLIC_API_PREFIXES = (
"/api/auth/login",
"/api/auth/register",
"/api/auth/forgot-password",
"/api/auth/reset-password",
"/api/auth/verify/",
"/api/legal-documents/",
"/api/clubs/public-directory",
"/api/version",
"/api/health/",
"/health",
)
# Mit Session, unabhängig vom account_state (Logout, Profil lesen, …)
AUTH_INFRA_PREFIXES = (
"/api/auth/logout",
"/api/auth/me",
"/api/auth/status",
"/api/auth/pin",
"/api/auth/resend-verification",
"/api/profiles/me",
"/api/me/entitlements",
)
# 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+)$")
def api_onboarding_gate_enabled() -> bool:
"""Produktions-Gate aktiv (ACCOUNT_GATE_API_ENFORCE=0 zum Abschalten)."""
return os.getenv("ACCOUNT_GATE_API_ENFORCE", "1").strip() == "1"
def _middleware_db_lookup_enabled() -> bool:
"""
Middleware-Session-Lookup nur mit echter DB (nicht in pytest TestClient ohne Postgres).
"""
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
return False
if os.getenv("PYTEST_CURRENT_TEST"):
return False
return True
def normalize_api_path(path: str) -> str:
p = (path or "").split("?", 1)[0].strip()
if not p.startswith("/"):
p = "/" + p
if len(p) > 1 and p.endswith("/"):
p = p[:-1]
return p
def is_public_api_path(path: str) -> bool:
p = normalize_api_path(path)
return any(p == pref or p.startswith(pref) for pref in PUBLIC_API_PREFIXES)
def _path_allowed_for_state(path: str, method: str, account_state: str, profile_id: int) -> bool:
p = normalize_api_path(path)
m = (method or "GET").upper()
for pref in AUTH_INFRA_PREFIXES:
if p == pref or p.startswith(pref + "/"):
return True
match = _PROFILE_MUTATION_RE.match(p)
if match and m in ("PUT", "PATCH") and int(match.group(1)) == int(profile_id):
return True
if account_state == "unverified":
return False
if account_state == "verified_pending_club":
for pref in PENDING_CLUB_PREFIXES:
if p == pref or p.startswith(pref + "/"):
return True
return False
return True
def resolve_account_state_for_token(cur, session_row: dict) -> str:
profile_id = int(session_row["profile_id"])
role = (session_row.get("role") or "").lower()
cur.execute(
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
(profile_id,),
)
prof = cur.fetchone()
email_verified = bool(prof.get("email_verified")) if prof else False
memberships = memberships_with_roles(cur, profile_id, active_only=True)
has_active = len(memberships) > 0
return resolve_account_state(
email_verified=email_verified,
global_role=role,
has_active_membership=has_active,
)
def check_api_onboarding_gate(
*,
path: str,
method: str,
profile_id: int,
account_state: str,
) -> Tuple[bool, Optional[str]]:
"""
Returns (allowed, reason).
active_member / platform_admin immer erlaubt (Domain).
"""
if not api_onboarding_gate_enabled():
return True, None
if account_state in ("active_member", "platform_admin"):
return True, None
if _path_allowed_for_state(path, method, account_state, profile_id):
return True, None
return False, f"account_state_{account_state}"
def evaluate_request_gate(token: Optional[str], path: str, method: str) -> Tuple[bool, Optional[str], Optional[str]]:
"""
Vollständige Prüfung inkl. Session-Lookup.
Returns: allowed, reason, account_state (für Logging)
"""
if not api_onboarding_gate_enabled() or not _middleware_db_lookup_enabled():
return True, None, None
p = normalize_api_path(path)
if not p.startswith("/api/"):
return True, None, None
if is_public_api_path(p):
return True, None, None
if not token:
return True, None, None
from auth import get_session
from db import get_db, get_cursor
session = get_session(token)
if not session:
return True, None, None
profile_id = int(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
account_state = resolve_account_state_for_token(cur, session)
allowed, reason = check_api_onboarding_gate(
path=p,
method=method,
profile_id=profile_id,
account_state=account_state,
)
return allowed, reason, account_state

View File

@ -5,7 +5,7 @@ Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / ex
"""
from __future__ import annotations
from typing import List, Optional, Sequence, Tuple
from typing import Any, Dict, List, Optional, Sequence, Tuple
from pydantic import BaseModel, Field
@ -31,6 +31,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes: Optional[str] = None
focus_hint: Optional[str] = None
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
planning_context: Optional[Dict[str, Any]] = None
def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
if not self.focus_areas_context:
@ -57,6 +58,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes: Optional[str] = None,
focus_area_hint: Optional[str] = None,
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
planning_context: Optional[Dict[str, Any]] = None,
) -> ExerciseFormAiPromptContext:
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
hint = (focus_area_hint or "").strip() or None
@ -68,6 +70,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes=trainer_notes,
focus_hint=hint,
focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
planning_context=dict(planning_context) if planning_context else None,
)
@classmethod

View File

@ -23,6 +23,7 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon
focus_areas_context=ctx.focus_area_tuples(),
preparation=ctx.preparation,
trainer_notes=ctx.trainer_notes,
planning_context=ctx.planning_context,
)

View File

@ -170,6 +170,10 @@ def get_effective_tier(profile_id: str, conn=None) -> str:
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
"""
DEPRECATED für Shinkan: Mitai-v9c-Profil-Limits Schema 001 ist archiviert (Migration 078).
Für Vereins-Kontingente: club_features.check_club_feature_access(club_id, feature_id).
Check if a profile has access to a feature.
Access hierarchy:
@ -315,6 +319,8 @@ def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
"""
DEPRECATED für Shinkan siehe club_features.increment_club_feature_usage.
Increment usage counter for a feature.
Creates usage record if it doesn't exist, with reset_at based on

285
backend/capabilities.py Normal file
View File

@ -0,0 +1,285 @@
"""
Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1).
Phase 2: probe_capability JSON-Log, kein Block (CAPABILITY_ENFORCE=0).
Phase 3+: CAPABILITY_ENFORCE=1 HTTP 403 bei fehlender Capability.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from fastapi import HTTPException
from account_lifecycle import account_state_satisfies
from club_tenancy import is_platform_admin
from db import get_db, get_cursor
if TYPE_CHECKING:
from tenant_context import TenantContext
def capability_enforcement_enabled() -> bool:
v = os.getenv("CAPABILITY_ENFORCE", "0").strip().lower()
return v in ("1", "true", "yes")
def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:
if club_id is None:
return []
for m in tenant.memberships or []:
if int(m.get("id") or 0) == int(club_id):
roles = m.get("roles") or []
if hasattr(roles, "tolist"):
roles = roles.tolist()
return list(roles)
return []
def check_capability(
cur,
tenant: "TenantContext",
capability_id: str,
*,
club_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
Prüft eine Capability für Tenant + optionalen Vereinskontext.
Returns: allowed, reason, account_state, club_roles, linked_feature_id
"""
account_state = getattr(tenant, "account_state", "active_member")
eff_club = club_id if club_id is not None else tenant.effective_club_id
club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else []
cur.execute(
"""
SELECT id, min_account_state, linked_feature_id, active, domain
FROM capabilities
WHERE id = %s
""",
(capability_id,),
)
cap = cur.fetchone()
if not cap or not cap.get("active"):
return {
"allowed": False,
"reason": "capability_not_found",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": None,
}
min_state = cap.get("min_account_state") or "active_member"
if not account_state_satisfies(account_state, min_state):
return {
"allowed": False,
"reason": "account_state_insufficient",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
domain = (cap.get("domain") or "").strip().lower()
# Kontingent-Bypass (konfigurierbar per portal_role / profile grants, ohne Plattform-Admin-Pflicht)
if domain == "quota_bypass":
role_lc = (tenant.global_role or "").lower()
cur.execute(
"""
SELECT 1 FROM portal_role_capability_grants
WHERE portal_role = %s AND capability_id = %s
LIMIT 1
""",
(role_lc, capability_id),
)
if cur.fetchone():
return {
"allowed": True,
"reason": "quota_bypass_portal_grant",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
cur.execute(
"""
SELECT 1 FROM profile_capability_grants
WHERE profile_id = %s AND capability_id = %s
LIMIT 1
""",
(tenant.profile_id, capability_id),
)
if cur.fetchone():
return {
"allowed": True,
"reason": "quota_bypass_profile_grant",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
return {
"allowed": False,
"reason": "quota_bypass_denied",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
# Plattform-Capabilities
if domain == "platform" or capability_id.startswith("platform."):
role_lc = (tenant.global_role or "").lower()
if not is_platform_admin(role_lc):
return {
"allowed": False,
"reason": "portal_role_required",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
cur.execute(
"""
SELECT 1 FROM portal_role_capability_grants
WHERE portal_role = %s AND capability_id = %s
LIMIT 1
""",
(role_lc, capability_id),
)
if not cur.fetchone():
cur.execute(
"""
SELECT 1 FROM profile_capability_grants
WHERE profile_id = %s AND capability_id = %s
LIMIT 1
""",
(tenant.profile_id, capability_id),
)
if not cur.fetchone():
return {
"allowed": False,
"reason": "portal_capability_denied",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
return {
"allowed": True,
"reason": "portal_granted",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
# Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9)
if is_platform_admin(tenant.global_role):
return {
"allowed": True,
"reason": "platform_admin_bypass",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
# Vereins-Capabilities: aktive Mitgliedschaft im Zielverein
if min_state == "active_member":
if eff_club is None:
return {
"allowed": False,
"reason": "no_club_context",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
if eff_club not in tenant.club_ids:
return {
"allowed": False,
"reason": "not_club_member",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
cur.execute(
"""
SELECT role_code FROM club_role_capability_grants
WHERE capability_id = %s
""",
(capability_id,),
)
required_roles = [r["role_code"] for r in cur.fetchall()]
if required_roles:
if not any(r in required_roles for r in club_roles):
return {
"allowed": False,
"reason": "club_role_denied",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
elif min_state == "active_member" and eff_club is not None:
# Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht
pass
return {
"allowed": True,
"reason": "granted",
"account_state": account_state,
"club_roles": club_roles,
"linked_feature_id": cap.get("linked_feature_id"),
}
def resolve_capabilities_map(
cur,
tenant: "TenantContext",
*,
club_id: Optional[int] = None,
) -> Dict[str, bool]:
"""Alle aktiven Capabilities → bool (für späteres /me/entitlements)."""
cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id")
ids = [r["id"] for r in cur.fetchall()]
out: Dict[str, bool] = {}
for cid in ids:
res = check_capability(cur, tenant, cid, club_id=club_id)
out[cid] = bool(res.get("allowed"))
return out
def probe_capability(
tenant: "TenantContext",
capability_id: str,
*,
action: str,
club_id: Optional[int] = None,
endpoint: Optional[str] = None,
conn=None,
) -> Dict[str, Any]:
"""Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1."""
from capability_logger import log_capability_check
def _run(c):
cur = get_cursor(c)
result = check_capability(cur, tenant, capability_id, club_id=club_id)
log_capability_check(
club_id=club_id if club_id is not None else tenant.effective_club_id,
profile_id=tenant.profile_id,
capability_id=capability_id,
action=action,
result=result,
endpoint=endpoint,
phase="enforce" if capability_enforcement_enabled() else "probe",
)
if capability_enforcement_enabled() and not result.get("allowed"):
raise HTTPException(
status_code=403,
detail=(
f"Keine Berechtigung für {capability_id} "
f"({result.get('reason', 'denied')})."
),
)
return result
if conn is not None:
return _run(conn)
with get_db() as c:
return _run(c)

View File

@ -0,0 +1,94 @@
"""
Audit: Welche Capabilities sind an Endpoints angebunden?
Für Admin-Matrix (Rollen & Rechte) und Roadmap bei neuem probe_capability hier eintragen.
"""
from __future__ import annotations
from typing import Any, Dict
# Endpoints rufen probe_capability auf (Log; Block nur bei CAPABILITY_ENFORCE=1)
WIRED_PROBE = frozenset(
{
"exercises.ai.suggest",
"exercises.ai.regenerate",
"exercises.create",
"exercises.media.upload",
"planning.ai.suggest",
"planning.ai.progression_path",
"club.creation_request.read_own",
"club.creation_request.create",
"club.creation_request.withdraw",
"platform.club_creation.approve",
}
)
# Kontingent-Verbrauch nach Erfolg (consume_club_feature_with_usage)
FEATURE_CONSUME_WIRED = frozenset(
{
"ai_calls",
}
)
def enforcement_status_for_capability(capability_id: str) -> Dict[str, Any]:
"""
Anzeige-Status für Superadmin-Matrix.
level: probe | legacy | platform | open | none
"""
cid = (capability_id or "").strip()
if cid in WIRED_PROBE:
return {
"level": "probe",
"label": "API vorbereitet (Log)",
"detail": "probe_capability am Endpoint; Hard-Block erst mit CAPABILITY_ENFORCE=1",
"implemented": True,
}
if cid.startswith("platform."):
if cid == "platform.admin.access":
return {
"level": "platform",
"label": "Plattform (Router-Guard)",
"detail": "RequireAdmin / Superadmin-Checks",
"implemented": True,
}
if cid in WIRED_PROBE:
pass
return {
"level": "platform",
"label": "Plattform (teilweise)",
"detail": "Meist Router-Guard; Capability-Probe nur wo eingetragen",
"implemented": cid in WIRED_PROBE,
}
if cid.startswith("club."):
return {
"level": "open",
"label": "Onboarding",
"detail": "Account-State / eigene Flows",
"implemented": cid in WIRED_PROBE,
}
# Vereins-Capabilities ohne Probe: Legacy club_tenancy (can_plan_in_club, has_club_role, …)
return {
"level": "legacy",
"label": "Nur Legacy-Rollen",
"detail": "Noch kein probe_capability — prüft can_plan_in_club / club_admin im Code",
"implemented": False,
}
def feature_consume_status(feature_id: str) -> Dict[str, Any]:
fid = (feature_id or "").strip()
if fid in FEATURE_CONSUME_WIRED:
return {
"level": "consume",
"label": "Verbrauch aktiv",
"detail": "consume_club_feature_with_usage + feature_usage in Response",
"implemented": True,
}
return {
"level": "inventory",
"label": "Bestand / Probe",
"detail": "Probe oder Live-Zählung; kein Consume nach Aktion",
"implemented": False,
}

View File

@ -0,0 +1,64 @@
"""
JSON-Log für Capability-Checks (M3 Phase 2 analog club_feature_logger).
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
def _log_dir() -> Path:
custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip()
if custom:
return Path(custom)
return Path("/app/logs")
capability_logger = logging.getLogger("shinkan.capability_usage")
capability_logger.setLevel(logging.INFO)
capability_logger.propagate = False
if not capability_logger.handlers:
log_dir = _log_dir()
try:
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "capability-usage.log"
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(message)s"))
capability_logger.addHandler(file_handler)
except OSError:
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s"))
capability_logger.addHandler(stream_handler)
def log_capability_check(
*,
club_id: Optional[int],
profile_id: Optional[int],
capability_id: str,
action: str,
result: Dict[str, Any],
endpoint: Optional[str] = None,
phase: str = "probe",
) -> None:
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"club_id": club_id,
"profile_id": profile_id,
"capability": capability_id,
"action": action,
"endpoint": endpoint,
"phase": phase,
"allowed": result.get("allowed", True),
"reason": result.get("reason", "unknown"),
"account_state": result.get("account_state"),
"club_roles": result.get("club_roles"),
"enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1",
}
capability_logger.info(json.dumps(entry, ensure_ascii=False))

View File

@ -0,0 +1,74 @@
"""
JSON-Log für Vereins-Feature-Zugriffe (Phase 2: nur Monitoring, kein Block).
Spez: CLUB_MEMBERSHIP_AND_FEATURES.v1.md §9 Phase 2 analog Mitai feature_logger.py.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
def _log_dir() -> Path:
custom = (os.getenv("CLUB_FEATURE_LOG_DIR") or "").strip()
if custom:
return Path(custom)
return Path("/app/logs")
feature_usage_logger = logging.getLogger("shinkan.club_feature_usage")
feature_usage_logger.setLevel(logging.INFO)
feature_usage_logger.propagate = False
if not feature_usage_logger.handlers:
log_dir = _log_dir()
try:
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "club-feature-usage.log"
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(message)s"))
feature_usage_logger.addHandler(file_handler)
except OSError:
# Dev ohne /app/logs: Fallback stderr
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("[club-feature-usage] %(message)s"))
feature_usage_logger.addHandler(stream_handler)
def log_club_feature_usage(
*,
club_id: Optional[int],
profile_id: Optional[int],
feature_id: str,
action: str,
access: Dict[str, Any],
endpoint: Optional[str] = None,
phase: str = "probe",
) -> None:
"""
Strukturiertes JSON-Log eines Feature-Checks.
phase: probe (Phase 2, non-blocking) | enforce (Phase 4, nach Block-Entscheid)
"""
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"club_id": club_id,
"profile_id": profile_id,
"feature": feature_id,
"action": action,
"endpoint": endpoint,
"phase": phase,
"plan_id": access.get("plan_id"),
"used": access.get("used", 0),
"limit": access.get("limit"),
"remaining": access.get("remaining"),
"allowed": access.get("allowed", True),
"reason": access.get("reason", "unknown"),
"enforcement": os.getenv("CLUB_FEATURE_ENFORCE", "0") == "1",
}
feature_usage_logger.info(json.dumps(entry, ensure_ascii=False))

713
backend/club_features.py Normal file
View File

@ -0,0 +1,713 @@
"""
Vereinsbezogene Feature-Limits (Mitai-v9c-Pattern, Subjekt club_id).
Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md
Phase 2 (M2): probe_club_feature_access JSON-Log, kein HTTP-Block.
Phase 4 (M5+): CLUB_FEATURE_ENFORCE=1 HTTP 403 + increment.
Verbrauch-Standard für Router:
probe_club_feature_access Business-Logik consume_club_feature_with_usage merge_feature_usage_into_response
Legacy profil-zentriert: auth.check_feature_access (001 / Mitai-Überbleibsel) nicht für Shinkan-Limits nutzen.
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, TYPE_CHECKING
from fastapi import HTTPException
from db import get_db, get_cursor
if TYPE_CHECKING:
from tenant_context import TenantContext
# Bestands-Features: Verbrauch = Live-Zählung in DB (nicht club_feature_usage)
_INVENTORY_FEATURES = frozenset(
{"exercises", "training_groups", "active_members", "training_programs"}
)
def _calculate_next_reset(reset_period: str, *, now: Optional[datetime] = None) -> Optional[datetime]:
"""Nächster Reset-Zeitpunkt; None bei 'never'."""
ref = now or datetime.now(timezone.utc)
if reset_period == "never":
return None
if reset_period == "daily":
tomorrow = ref.date() + timedelta(days=1)
return datetime.combine(tomorrow, datetime.min.time(), tzinfo=timezone.utc)
if reset_period == "monthly":
if ref.month == 12:
return datetime(ref.year + 1, 1, 1, tzinfo=timezone.utc)
return datetime(ref.year, ref.month + 1, 1, tzinfo=timezone.utc)
return None
def _normalize_limit(raw: Any) -> Optional[int]:
"""NULL = unbegrenzt; -1 (Legacy 001) wird als unbegrenzt behandelt."""
if raw is None:
return None
try:
v = int(raw)
except (TypeError, ValueError):
return None
if v < 0:
return None
return v
def get_effective_club_plan(cur, club_id: int) -> str:
"""
Effektiver Plan für einen Verein.
1. Aktiver club_access_grants mit plan_id (Zeitfenster, neueste ends_at)
2. club_subscriptions.status = 'active' plan_id
3. Fallback 'free'
"""
cur.execute(
"""
SELECT plan_id
FROM club_access_grants
WHERE club_id = %s
AND plan_id IS NOT NULL
AND starts_at <= NOW()
AND ends_at > NOW()
ORDER BY ends_at DESC
LIMIT 1
""",
(club_id,),
)
grant = cur.fetchone()
if grant and grant.get("plan_id"):
return str(grant["plan_id"])
cur.execute(
"""
SELECT plan_id
FROM club_subscriptions
WHERE club_id = %s AND status = 'active'
LIMIT 1
""",
(club_id,),
)
sub = cur.fetchone()
if sub and sub.get("plan_id"):
return str(sub["plan_id"])
return "free"
def _resolve_club_limit(cur, club_id: int, feature_id: str, feature_row: dict) -> Optional[int]:
"""Limit-Wert: Override > Plan > Feature-Default."""
cur.execute(
"""
SELECT limit_value
FROM club_feature_overrides
WHERE club_id = %s AND feature_id = %s
""",
(club_id, feature_id),
)
override = cur.fetchone()
if override is not None:
return _normalize_limit(override.get("limit_value"))
plan_id = get_effective_club_plan(cur, club_id)
cur.execute(
"""
SELECT limit_value
FROM club_plan_limits
WHERE plan_id = %s AND feature_id = %s
""",
(plan_id, feature_id),
)
plan_lim = cur.fetchone()
if plan_lim is not None:
return _normalize_limit(plan_lim.get("limit_value"))
return _normalize_limit(feature_row.get("default_limit"))
def _live_inventory_count(cur, club_id: int, feature_id: str) -> Optional[int]:
"""Aktueller Bestand für reset_period=never Features."""
if feature_id == "exercises":
cur.execute(
"""
SELECT COUNT(*)::int AS c
FROM exercises
WHERE club_id = %s AND status != 'archived'
""",
(club_id,),
)
elif feature_id == "training_groups":
cur.execute(
"SELECT COUNT(*)::int AS c FROM training_groups WHERE club_id = %s",
(club_id,),
)
elif feature_id == "active_members":
cur.execute(
"""
SELECT COUNT(*)::int AS c
FROM club_members
WHERE club_id = %s AND status = 'active'
""",
(club_id,),
)
elif feature_id == "training_programs":
cur.execute(
"""
SELECT COUNT(*)::int AS c FROM (
SELECT id FROM training_framework_programs WHERE club_id = %s
UNION ALL
SELECT id FROM training_modules WHERE club_id = %s
) t
""",
(club_id, club_id),
)
else:
return None
row = cur.fetchone()
return int(row["c"] or 0) if row else 0
def resolve_club_id_for_probe(
tenant: "TenantContext",
*,
object_club_id: Optional[int] = None,
) -> Optional[int]:
"""Verein für Feature-Probe: explizites Objekt > effective_club_id."""
if object_club_id is not None:
return int(object_club_id)
eff = getattr(tenant, "effective_club_id", None)
return int(eff) if eff is not None else None
def _maybe_reset_usage(cur, conn, club_id: int, feature_id: str, feature_row: dict, usage_row: Optional[dict]) -> int:
"""Setzt Zähler zurück wenn reset_at überschritten; gibt aktuellen used zurück."""
used = int(usage_row.get("usage_count") or 0) if usage_row else 0
reset_at = usage_row.get("reset_at") if usage_row else None
period = (feature_row.get("reset_period") or "never").strip().lower()
if not usage_row or not reset_at or period == "never":
return used
now = datetime.now(timezone.utc)
ra = reset_at
if hasattr(ra, "tzinfo") and ra.tzinfo is None:
ra = ra.replace(tzinfo=timezone.utc)
if ra and now > ra:
next_reset = _calculate_next_reset(period, now=now)
cur.execute(
"""
UPDATE club_feature_usage
SET usage_count = 0, reset_at = %s, updated_at = NOW()
WHERE club_id = %s AND feature_id = %s
""",
(next_reset, club_id, feature_id),
)
conn.commit()
return 0
return used
def check_club_feature_access(
club_id: int,
feature_id: str,
*,
conn=None,
) -> Dict[str, Any]:
"""
Prüft Vereins-Kontingent für ein Feature.
Returns:
allowed, limit, used, remaining, reason, plan_id, reset_at (optional)
"""
if conn is not None:
return _check_club_impl(club_id, feature_id, conn)
with get_db() as c:
return _check_club_impl(club_id, feature_id, c)
def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, limit_type, reset_period, default_limit, active, enforcement_subject
FROM features
WHERE id = %s AND app = 'shinkan'
""",
(feature_id,),
)
feature = cur.fetchone()
if not feature or not feature.get("active"):
return {
"allowed": False,
"limit": None,
"used": 0,
"remaining": None,
"reason": "feature_not_found",
"plan_id": get_effective_club_plan(cur, club_id),
}
plan_id = get_effective_club_plan(cur, club_id)
limit = _resolve_club_limit(cur, club_id, feature_id, feature)
limit_type = (feature.get("limit_type") or "count").strip().lower()
if limit_type == "boolean":
allowed = limit == 1
return {
"allowed": allowed,
"limit": limit,
"used": 0,
"remaining": None,
"reason": "enabled" if allowed else "feature_disabled",
"plan_id": plan_id,
}
cur.execute(
"""
SELECT usage_count, reset_at
FROM club_feature_usage
WHERE club_id = %s AND feature_id = %s
""",
(club_id, feature_id),
)
usage = cur.fetchone()
used = _maybe_reset_usage(cur, conn, club_id, feature_id, feature, usage)
period = (feature.get("reset_period") or "never").strip().lower()
if period == "never" and feature_id in _INVENTORY_FEATURES:
inv = _live_inventory_count(cur, club_id, feature_id)
if inv is not None:
used = inv
if limit is None:
return {
"allowed": True,
"limit": None,
"used": used,
"remaining": None,
"reason": "unlimited",
"plan_id": plan_id,
"reset_at": usage.get("reset_at") if usage else None,
}
if limit == 0:
return {
"allowed": False,
"limit": 0,
"used": used,
"remaining": 0,
"reason": "feature_disabled",
"plan_id": plan_id,
"reset_at": usage.get("reset_at") if usage else None,
}
allowed = used < limit
return {
"allowed": allowed,
"limit": limit,
"used": used,
"remaining": max(0, limit - used),
"reason": "within_limit" if allowed else "limit_exceeded",
"plan_id": plan_id,
"reset_at": usage.get("reset_at") if usage else None,
}
def club_feature_enforcement_enabled() -> bool:
"""Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes)."""
v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower()
return v in ("1", "true", "yes")
def probe_club_feature_access(
*,
feature_id: str,
action: str,
club_id: Optional[int] = None,
profile_id: Optional[int] = None,
portal_role: Optional[str] = None,
endpoint: Optional[str] = None,
tenant: Optional["TenantContext"] = None,
conn=None,
) -> Dict[str, Any]:
"""
Phase 2: Prüft Vereins-Kontingent, schreibt JSON-Log, blockiert standardmäßig nicht.
Bei CLUB_FEATURE_ENFORCE=1: HTTP 403 wenn nicht allowed.
"""
from club_feature_logger import log_club_feature_usage
if club_id is None:
access = {
"allowed": not club_feature_enforcement_enabled(),
"limit": None,
"used": 0,
"remaining": None,
"reason": "no_club_context",
"plan_id": None,
}
log_club_feature_usage(
club_id=None,
profile_id=profile_id,
feature_id=feature_id,
action=action,
access=access,
endpoint=endpoint,
phase="enforce" if club_feature_enforcement_enabled() else "probe",
)
if club_feature_enforcement_enabled() and not access.get("allowed"):
raise HTTPException(
status_code=403,
detail=(
f"Kein Vereinskontext für {feature_id}"
"aktiven Verein wählen (X-Active-Club-Id)."
),
)
return access
def _resolve_access(connection):
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
cur = get_cursor(connection)
if is_club_feature_quota_bypassed(
cur,
profile_id=profile_id,
portal_role=portal_role,
feature_id=feature_id,
tenant=tenant,
):
plan_id = get_effective_club_plan(cur, int(club_id))
return quota_bypass_access(
feature_id=feature_id,
club_id=int(club_id),
plan_id=plan_id,
)
return check_club_feature_access(club_id, feature_id, conn=connection)
if conn is not None:
access = _resolve_access(conn)
else:
with get_db() as c:
access = _resolve_access(c)
log_club_feature_usage(
club_id=club_id,
profile_id=profile_id,
feature_id=feature_id,
action=action,
access=access,
endpoint=endpoint,
phase="enforce" if club_feature_enforcement_enabled() else "probe",
)
if club_feature_enforcement_enabled() and not access.get("allowed"):
limit = access.get("limit")
used = access.get("used", 0)
detail = (
f"Kontingent überschritten für {feature_id} "
f"({used}/{limit if limit is not None else ''}). "
f"Grund: {access.get('reason', 'limit_exceeded')}."
)
raise HTTPException(status_code=403, detail=detail)
return access
def consume_club_feature(
*,
feature_id: str,
club_id: Optional[int],
profile_id: Optional[int] = None,
portal_role: Optional[str] = None,
action: Optional[str] = None,
amount: int = 1,
conn=None,
) -> None:
"""
Phase 4 (M5): Zähler nach erfolgreichem Verbrauch erhöhen.
Nur wenn club_id gesetzt (Vereins-Kontingent); amount = Anzahl LLM/API-Verbrauchseinheiten.
Plattform-Ausnahmen (superadmin, konfigurierte Rollen/Profile) werden nicht gezählt.
"""
if club_id is None:
return
def _is_exempt(connection) -> bool:
from club_quota_bypass import is_club_feature_quota_bypassed
cur = get_cursor(connection)
return is_club_feature_quota_bypassed(
cur,
profile_id=profile_id,
portal_role=portal_role,
feature_id=feature_id,
)
if conn is not None:
if _is_exempt(conn):
return
else:
with get_db() as c:
if _is_exempt(c):
return
try:
n = int(amount)
except (TypeError, ValueError):
n = 1
if n < 1:
return
for _ in range(n):
increment_club_feature_usage(
int(club_id),
feature_id,
profile_id=profile_id,
action=action,
conn=conn,
)
def _log_consume(connection) -> None:
from club_feature_logger import log_club_feature_usage
access = check_club_feature_access(int(club_id), feature_id, conn=connection)
log_club_feature_usage(
club_id=int(club_id),
profile_id=profile_id,
feature_id=feature_id,
action=action or "consume",
access=access,
phase="consume",
)
if conn is not None:
_log_consume(conn)
else:
with get_db() as c:
_log_consume(c)
def consume_club_feature_with_usage(
*,
feature_id: str,
club_id: Optional[int],
profile_id: Optional[int] = None,
portal_role: Optional[str] = None,
action: Optional[str] = None,
amount: int = 1,
cur,
tenant: Optional["TenantContext"] = None,
conn=None,
) -> Optional[Dict[str, Dict[str, Any]]]:
"""
Standard nach erfolgreichem Verbrauch: zählen, protokollieren, Snapshot für Response.
Alle Endpoints mit Vereins-Kontingent-Verbrauch nutzen diese Funktion und
``merge_feature_usage_into_response`` kein duplizierter Einzelcode pro Route.
"""
consume_club_feature(
feature_id=feature_id,
club_id=club_id,
profile_id=profile_id,
portal_role=portal_role,
action=action,
amount=amount,
conn=conn,
)
if club_id is None:
return None
return {
feature_id: club_feature_usage_for_api(
cur,
club_id=int(club_id),
feature_id=feature_id,
profile_id=profile_id,
portal_role=portal_role,
tenant=tenant,
conn=conn,
),
}
def merge_feature_usage_into_response(
payload: Any,
feature_usage: Optional[Dict[str, Dict[str, Any]]],
) -> Any:
"""Standard-Einbettung ``feature_usage`` in JSON-Responses."""
if not feature_usage or not isinstance(payload, dict):
return payload
return {**payload, "feature_usage": feature_usage}
def club_feature_usage_for_api(
cur,
*,
club_id: int,
feature_id: str,
profile_id: Optional[int] = None,
portal_role: Optional[str] = None,
tenant: Optional["TenantContext"] = None,
conn=None,
) -> Dict[str, Any]:
"""Feature-Zustand wie GET /me/entitlements → features[feature_id] (nach Verbrauch)."""
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
db_conn = conn if conn is not None else cur.connection
access = check_club_feature_access(int(club_id), feature_id, conn=db_conn)
plan_id = access.get("plan_id") or get_effective_club_plan(cur, int(club_id))
if is_club_feature_quota_bypassed(
cur,
profile_id=profile_id,
portal_role=portal_role,
feature_id=feature_id,
tenant=tenant,
):
ex = quota_bypass_access(
feature_id=feature_id,
club_id=int(club_id),
plan_id=plan_id,
)
reset_at = access.get("reset_at")
return {
"allowed": True,
"used": access.get("used"),
"limit": None,
"remaining": None,
"reason": ex.get("reason"),
"platform_exempt": True,
"reset_at": reset_at.isoformat() if hasattr(reset_at, "isoformat") else reset_at,
}
return {
"allowed": access.get("allowed"),
"used": access.get("used"),
"limit": access.get("limit"),
"remaining": access.get("remaining"),
"reason": access.get("reason"),
"platform_exempt": False,
"reset_at": access.get("reset_at").isoformat()
if access.get("reset_at") is not None and hasattr(access.get("reset_at"), "isoformat")
else access.get("reset_at"),
}
def increment_club_feature_usage(
club_id: int,
feature_id: str,
*,
profile_id: Optional[int] = None,
action: Optional[str] = None,
conn=None,
) -> None:
"""Erhöht Vereins-Zähler (nur bei neuem Verbrauch / INSERT-Pfad aufrufen)."""
def _run(c):
cur = get_cursor(c)
cur.execute(
"""
SELECT reset_period, limit_type
FROM features
WHERE id = %s AND app = 'shinkan' AND active = true
""",
(feature_id,),
)
feature = cur.fetchone()
if not feature:
return
if (feature.get("limit_type") or "count").strip().lower() == "boolean":
return
period = (feature.get("reset_period") or "never").strip().lower()
next_reset = _calculate_next_reset(period)
cur.execute(
"""
INSERT INTO club_feature_usage (club_id, feature_id, usage_count, reset_at, last_used_at)
VALUES (%s, %s, 1, %s, NOW())
ON CONFLICT (club_id, feature_id)
DO UPDATE SET
usage_count = club_feature_usage.usage_count + 1,
last_used_at = NOW(),
updated_at = NOW()
""",
(club_id, feature_id, next_reset),
)
if profile_id is not None or action:
cur.execute(
"""
INSERT INTO club_feature_usage_events (club_id, feature_id, profile_id, action)
VALUES (%s, %s, %s, %s)
""",
(club_id, feature_id, profile_id, action or feature_id),
)
if conn is not None:
_run(conn)
else:
with get_db() as c:
_run(c)
def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern)."""
db_conn = conn if conn is not None else cur.connection
plan_id = get_effective_club_plan(cur, club_id)
cur.execute(
"""
SELECT id, name, category, limit_type, reset_period
FROM features
WHERE app = 'shinkan' AND active = true
ORDER BY category, id
"""
)
rows = cur.fetchall()
features_out = []
for row in rows:
fid = row["id"]
access = _check_club_impl(club_id, fid, db_conn)
features_out.append(
{
"id": fid,
"name": row.get("name"),
"category": row.get("category"),
"limit_type": row.get("limit_type"),
"reset_period": row.get("reset_period"),
"allowed": access.get("allowed"),
"limit": access.get("limit"),
"used": access.get("used"),
"remaining": access.get("remaining"),
"reason": access.get("reason"),
"reset_at": access.get("reset_at"),
}
)
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}
def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
"""Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements)."""
raw = list_club_entitlements(cur, club_id, conn=conn)
features_dict: Dict[str, Any] = {}
for row in raw.get("features") or []:
fid = row["id"]
features_dict[fid] = {
"name": row.get("name"),
"category": row.get("category"),
"limit_type": row.get("limit_type"),
"reset_period": row.get("reset_period"),
"allowed": row.get("allowed"),
"limit": row.get("limit"),
"used": row.get("used"),
"remaining": row.get("remaining"),
"reason": row.get("reason"),
"reset_at": row.get("reset_at"),
}
return {
"club_id": raw.get("club_id"),
"plan_id": raw.get("plan_id"),
"features": features_dict,
}

View File

@ -0,0 +1,180 @@
"""
Vereins-Kontingent-Bypass über das Capability-System (kein Parallel-Rechtemodell).
Capabilities:
- platform.club_quota.bypass alle Vereins-Features (Portal-Admin, Grant via portal_role)
- platform.club_quota.bypass.{feature_id} ein Feature (domain quota_bypass, auch für Nicht-Admins per Grant)
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from tenant_context import TenantContext
QUOTA_BYPASS_ALL = "platform.club_quota.bypass"
QUOTA_BYPASS_FEATURE_PREFIX = "platform.club_quota.bypass."
def quota_bypass_capability_id_for_feature(feature_id: str) -> str:
return f"{QUOTA_BYPASS_FEATURE_PREFIX}{feature_id}"
def ensure_quota_bypass_capability(cur, feature_id: str) -> str:
"""Legt feature-spezifische Bypass-Capability an falls nötig."""
cap_id = quota_bypass_capability_id_for_feature(feature_id)
cur.execute(
"""
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
VALUES (%s, %s, 'quota_bypass', 'active_member', %s)
ON CONFLICT (id) DO NOTHING
""",
(cap_id, f"Vereins-Kontingent umgehen: {feature_id}", feature_id),
)
return cap_id
def _bypass_capability_ids(cur, feature_id: str) -> List[str]:
ids: List[str] = [QUOTA_BYPASS_ALL, quota_bypass_capability_id_for_feature(feature_id)]
cur.execute(
"""
SELECT id FROM capabilities
WHERE active = true
AND domain = 'quota_bypass'
AND linked_feature_id = %s
AND id <> %s
""",
(feature_id, quota_bypass_capability_id_for_feature(feature_id)),
)
for row in cur.fetchall():
cid = row.get("id")
if cid and cid not in ids:
ids.append(str(cid))
return ids
def _portal_role_has_grant(cur, portal_role: str, capability_id: str) -> bool:
role = (portal_role or "").strip().lower()
if not role:
return False
cur.execute(
"""
SELECT 1 FROM portal_role_capability_grants
WHERE portal_role = %s AND capability_id = %s
LIMIT 1
""",
(role, capability_id),
)
return cur.fetchone() is not None
def _profile_has_grant(cur, profile_id: int, capability_id: str) -> bool:
cur.execute(
"""
SELECT 1 FROM profile_capability_grants
WHERE profile_id = %s AND capability_id = %s
LIMIT 1
""",
(int(profile_id), capability_id),
)
return cur.fetchone() is not None
def is_club_feature_quota_bypassed(
cur,
*,
profile_id: Optional[int],
portal_role: Optional[str],
feature_id: str,
tenant: Optional["TenantContext"] = None,
) -> bool:
"""
True wenn ein konfigurierter Capability-Grant das Vereins-Kontingent für feature_id umgeht.
"""
if tenant is not None:
from capabilities import check_capability
for cap_id in _bypass_capability_ids(cur, feature_id):
if check_capability(cur, tenant, cap_id).get("allowed"):
return True
return False
for cap_id in _bypass_capability_ids(cur, feature_id):
if _portal_role_has_grant(cur, portal_role or "", cap_id):
return True
if profile_id is not None and _profile_has_grant(cur, int(profile_id), cap_id):
return True
return False
def quota_bypass_access(
*,
feature_id: str,
club_id: Optional[int] = None,
plan_id: Optional[str] = None,
capability_id: Optional[str] = None,
) -> Dict[str, Any]:
return {
"allowed": True,
"limit": None,
"used": 0,
"remaining": None,
"reason": "capability_quota_bypass",
"platform_exempt": True,
"quota_bypass_capability": capability_id,
"plan_id": plan_id,
"club_id": club_id,
"feature_id": feature_id,
}
def list_quota_bypass_grants(cur) -> Dict[str, Any]:
"""Admin: alle Grants zu Kontingent-Bypass-Capabilities."""
cur.execute(
"""
SELECT g.portal_role, g.capability_id, c.name AS capability_name,
c.linked_feature_id, c.domain
FROM portal_role_capability_grants g
INNER JOIN capabilities c ON c.id = g.capability_id
WHERE g.capability_id = %s
OR g.capability_id LIKE %s
OR c.domain = 'quota_bypass'
ORDER BY g.portal_role, g.capability_id
""",
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
)
portal_grants = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT g.profile_id, p.email, p.name AS profile_name,
g.capability_id, c.name AS capability_name, c.linked_feature_id,
g.reason, g.granted_by_profile_id, g.created_at
FROM profile_capability_grants g
INNER JOIN profiles p ON p.id = g.profile_id
INNER JOIN capabilities c ON c.id = g.capability_id
WHERE g.capability_id = %s
OR g.capability_id LIKE %s
OR c.domain = 'quota_bypass'
ORDER BY g.profile_id, g.capability_id
""",
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
)
profile_grants = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, name, domain, linked_feature_id
FROM capabilities
WHERE id = %s OR id LIKE %s OR domain = 'quota_bypass'
ORDER BY id
""",
(QUOTA_BYPASS_ALL, f"{QUOTA_BYPASS_FEATURE_PREFIX}%"),
)
capabilities = [dict(r) for r in cur.fetchall()]
return {
"capabilities": capabilities,
"portal_role_grants": portal_grants,
"profile_grants": profile_grants,
}

113
backend/entitlements.py Normal file
View File

@ -0,0 +1,113 @@
"""
Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional, TYPE_CHECKING
from fastapi import HTTPException
from capabilities import club_roles_in_club, resolve_capabilities_map
from club_quota_bypass import is_club_feature_quota_bypassed, quota_bypass_access
from club_features import club_features_map
from club_tenancy import is_platform_admin
from tenant_context import _club_exists
if TYPE_CHECKING:
from tenant_context import TenantContext
def _serialize_reset_at(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=None).isoformat() + "Z"
return value.isoformat()
return str(value)
def _resolve_target_club_id(
cur,
tenant: "TenantContext",
club_id: Optional[int],
) -> Optional[int]:
"""Effektiver Verein für Entitlements (Query > Tenant)."""
target = int(club_id) if club_id is not None else tenant.effective_club_id
if target is None:
return None
if is_platform_admin(tenant.global_role):
if not _club_exists(cur, target):
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
return target
if target not in tenant.club_ids:
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
return target
def build_me_entitlements(
cur,
tenant: "TenantContext",
*,
club_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
Kombiniert Account-Status, Capabilities und Feature-Kontingente.
"""
target_club = _resolve_target_club_id(cur, tenant, club_id)
club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
features: Dict[str, Any] = {}
plan_id = None
if target_club is not None:
raw = club_features_map(cur, target_club)
plan_id = raw.get("plan_id")
for fid, row in (raw.get("features") or {}).items():
if is_club_feature_quota_bypassed(
cur,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
feature_id=fid,
tenant=tenant,
):
ex = quota_bypass_access(
feature_id=fid,
club_id=target_club,
plan_id=plan_id,
)
features[fid] = {
"allowed": True,
"used": row.get("used"),
"limit": None,
"remaining": None,
"reset_at": _serialize_reset_at(row.get("reset_at")),
"reason": ex.get("reason"),
"platform_exempt": True,
}
else:
features[fid] = {
"allowed": row.get("allowed"),
"used": row.get("used"),
"limit": row.get("limit"),
"remaining": row.get("remaining"),
"reset_at": _serialize_reset_at(row.get("reset_at")),
"reason": row.get("reason"),
"platform_exempt": False,
}
return {
"account_state": tenant.account_state,
"portal_role": tenant.global_role,
"club_id": target_club,
"plan_id": plan_id,
"club_roles": club_roles,
"capabilities": capabilities,
"features": features,
}

View File

@ -650,10 +650,13 @@ def build_exercise_placeholder_variables(
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
planning_context: Optional[Mapping[str, Any]] = None,
) -> Dict[str, str]:
"""
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
"""
from planning_exercise_form_context import planning_context_prompt_variables
s = (slug or "").strip().lower()
if s == "pipeline":
return {}
@ -671,8 +674,19 @@ def build_exercise_placeholder_variables(
"exercise_preparation": p_plain or "-",
"exercise_trainer_notes": n_plain or "-",
}
ctx.update(planning_context_prompt_variables(planning_context))
if s == "exercise_summary":
return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")}
return {
k: ctx[k]
for k in (
"exercise_title",
"exercise_focus_area",
"exercise_goal",
"exercise_execution",
"planning_context_json",
"has_planning_context",
)
}
if s == "exercise_instruction_rewrite":
return ctx
if s == "exercise_skill_suggestions":
@ -893,6 +907,7 @@ def run_exercise_ai_suggestion(
execution=execution,
focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context,
planning_context=form_ctx.planning_context,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@ -938,6 +953,7 @@ def run_exercise_ai_suggestion(
execution=execution,
focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context,
planning_context=form_ctx.planning_context,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@ -1015,6 +1031,7 @@ def run_exercise_ai_suggestion(
trainer_notes=trainer_notes,
focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context,
planning_context=form_ctx.planning_context,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e

View File

@ -52,6 +52,28 @@ else:
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
sys.exit(1)
# Registry-first: Module → DB (nur registrierte Rechte/Kontingente in Admin-Matrix)
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
try:
from rights_registry import sync_rights_registry_to_db
counts = sync_rights_registry_to_db()
print(
f"[OK] Rights registry sync: {counts['capabilities']} capabilities, "
f"{counts['features']} features"
)
except Exception as e:
print(f"[FAIL] Rights registry sync: {e}")
sys.exit(1)
from club_features import club_feature_enforcement_enabled
_cfe = os.getenv("CLUB_FEATURE_ENFORCE", "0")
print(
f"[OK] CLUB_FEATURE_ENFORCE raw={_cfe!r} "
f"active={club_feature_enforcement_enabled()}"
)
from routers.auth import limiter as auth_rate_limiter
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
@ -87,6 +109,34 @@ app.add_middleware(
)
@app.middleware("http")
async def account_onboarding_api_gate(request: Request, call_next):
"""
Phase A: Domänen-APIs für unverified / verified_pending_club sperren.
Siehe account_onboarding_gate.py und MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1
"""
from account_onboarding_gate import evaluate_request_gate
token = request.headers.get("x-auth-token") or request.headers.get("X-Auth-Token")
allowed, reason, _state = evaluate_request_gate(
token,
request.url.path,
request.method,
)
if not allowed:
return JSONResponse(
status_code=403,
content={
"detail": (
"Zugriff erst nach E-Mail-Bestätigung und Vereinsmitgliedschaft möglich. "
"Du kannst einen Beitrittsantrag stellen oder dein Konto in den Einstellungen verwalten."
),
"reason": reason,
},
)
return await call_next(request)
@app.middleware("http")
async def add_api_security_headers(request: Request, call_next):
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
@ -193,7 +243,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, 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, admin_rights, 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)
@ -202,8 +252,11 @@ 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(admin_rights.router)
app.include_router(me_entitlements.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)

View File

@ -0,0 +1,74 @@
-- Migration 078: Planungs-KI Phase F — Progressions-Roadmap Prompts (Zielanalyse + Roadmap)
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, output_schema, is_system_default, default_template, active, sort_order
)
SELECT
'planning_progression_goal_analysis',
'Progressions-Roadmap Zielanalyse',
'Phase A: Ist-/Soll-Zustand und Erfolgskriterien für einen Progressionsgraphen (ohne Gruppenkontext).',
$t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Wichtig: Keine Gruppenanalyse nur didaktischer Pfad für die Technik/das Thema.
Antworte NUR mit JSON:
{
"primary_topic": "Mae Geri",
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
"target_state": "Konkreter Zielzustand der Progression",
"success_criteria": ["messbare Kriterien"],
"constraints": { "partner_required": false }
}$t$,
'training',
'json',
'{"type":"object","properties":{"primary_topic":{"type":"string"},"target_state":{"type":"string"},"success_criteria":{"type":"array"}}}'::jsonb,
true,
NULL,
true,
14
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_goal_analysis');
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, output_schema, is_system_default, default_template, active, sort_order
)
SELECT
'planning_progression_roadmap',
'Progressions-Roadmap Major Steps',
'Phase B: 812 micro_objectives, Konsolidierung auf N major_steps.',
$t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Semantic Brief: {{semantic_brief_json}}
Anzahl Major Steps (N): {{max_steps}}
Erzeuge zuerst 812 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
Antworte NUR mit JSON:
{
"micro_objectives": [
{ "id": "m1", "phase": "grundlage", "title": "", "weight": 0.9, "depends_on": [] }
],
"major_steps": [
{ "index": 0, "phase": "grundlage", "learning_goal": "", "consolidates": ["m1","m2"], "rationale": "" }
],
"consolidation_notes": [""]
}$t$,
'training',
'json',
'{"type":"object","properties":{"micro_objectives":{"type":"array"},"major_steps":{"type":"array"},"consolidation_notes":{"type":"array"}}}'::jsonb,
true,
NULL,
true,
15
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_roadmap');
UPDATE ai_prompts SET default_template = template
WHERE slug IN ('planning_progression_goal_analysis', 'planning_progression_roadmap')
AND (default_template IS NULL OR TRIM(default_template) = '');

View File

@ -0,0 +1,286 @@
-- Migration 078: Vereins-Feature-Registry (Mitai-v9c-Pattern) + club_plans/subscriptions
-- Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md (M1)
-- Legacy 001 (SERIAL features, profile tier_limits) wird archiviert, nicht gelöscht.
-- ── 1. Legacy-Tabellen archivieren (nur alte Struktur) ─────────────────────
DO $migration$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'features'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'name'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type'
) THEN
-- Nach abgebrochenem Erstversuch kann features_legacy_001 schon existieren
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'features_legacy_001'
) THEN
DROP TABLE features;
ELSE
ALTER TABLE features RENAME TO features_legacy_001;
END IF;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'tier_limits'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'tier_limits' AND column_name = 'tier'
) THEN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'tier_limits_legacy_001'
) THEN
DROP TABLE tier_limits;
ELSE
ALTER TABLE tier_limits RENAME TO tier_limits_legacy_001;
END IF;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'user_feature_usage'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'user_feature_usage' AND column_name = 'profile_id'
) THEN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'user_feature_usage_legacy_001'
) THEN
DROP TABLE user_feature_usage;
ELSE
ALTER TABLE user_feature_usage RENAME TO user_feature_usage_legacy_001;
END IF;
END IF;
END
$migration$;
-- ── 2. Feature-Registry (TEXT-PK, app=shinkan) ────────────────────────────
CREATE TABLE IF NOT EXISTS features (
id TEXT PRIMARY KEY,
app TEXT NOT NULL DEFAULT 'shinkan',
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'content',
limit_type TEXT NOT NULL DEFAULT 'count'
CHECK (limit_type IN ('count', 'boolean')),
reset_period TEXT NOT NULL DEFAULT 'never'
CHECK (reset_period IN ('never', 'daily', 'monthly')),
default_limit INTEGER,
enforcement_subject TEXT NOT NULL DEFAULT 'club'
CHECK (enforcement_subject IN ('club', 'profile', 'portal')),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_features_app ON features(app) WHERE active = true;
-- ── 3. Vereins-Produkte ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS club_plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price_monthly_cents INTEGER,
price_yearly_cents INTEGER,
stripe_price_id_monthly TEXT,
stripe_price_id_yearly TEXT,
active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS club_plan_limits (
id SERIAL PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
limit_value INTEGER,
UNIQUE (plan_id, feature_id)
);
CREATE INDEX IF NOT EXISTS idx_club_plan_limits_plan ON club_plan_limits(plan_id);
CREATE TABLE IF NOT EXISTS club_subscriptions (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES club_plans(id),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'trial', 'past_due', 'cancelled')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ends_at TIMESTAMPTZ,
trial_ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (club_id)
);
CREATE INDEX IF NOT EXISTS idx_club_subscriptions_plan ON club_subscriptions(plan_id);
CREATE TABLE IF NOT EXISTS club_feature_overrides (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
limit_value INTEGER NOT NULL,
reason TEXT,
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (club_id, feature_id)
);
CREATE TABLE IF NOT EXISTS club_access_grants (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
plan_id TEXT REFERENCES club_plans(id) ON DELETE SET NULL,
feature_id TEXT REFERENCES features(id) ON DELETE SET NULL,
grant_limit INTEGER,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
reason TEXT,
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_club_access_grants_club ON club_access_grants(club_id);
CREATE INDEX IF NOT EXISTS idx_club_access_grants_window ON club_access_grants(club_id, starts_at, ends_at);
CREATE TABLE IF NOT EXISTS club_feature_usage (
id SERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
usage_count INTEGER NOT NULL DEFAULT 0,
reset_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (club_id, feature_id)
);
CREATE INDEX IF NOT EXISTS idx_club_feature_usage_club ON club_feature_usage(club_id);
CREATE TABLE IF NOT EXISTS club_feature_usage_events (
id BIGSERIAL PRIMARY KEY,
club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
action TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_club_feature_usage_events_club
ON club_feature_usage_events(club_id, created_at DESC);
-- ── 4. Seed: Features ─────────────────────────────────────────────────────
INSERT INTO features (id, app, name, description, category, limit_type, reset_period, default_limit, enforcement_subject)
VALUES
('exercises', 'shinkan', 'Übungen', 'Anzahl Übungen im Verein (Bestand)', 'content', 'count', 'never', 100, 'club'),
('exercise_media', 'shinkan', 'Medien-Uploads', 'Medien-Uploads pro Monat', 'content', 'count', 'monthly', 20, 'club'),
('training_units', 'shinkan', 'Trainingseinheiten', 'Trainingseinheiten pro Monat', 'planning', 'count', 'monthly', 40, 'club'),
('training_programs', 'shinkan', 'Trainingsprogramme', 'Module und Rahmenprogramme (Bestand)', 'planning', 'count', 'never', 5, 'club'),
('training_groups', 'shinkan', 'Trainingsgruppen', 'Anzahl Trainingsgruppen', 'org', 'count', 'never', 10, 'club'),
('active_members', 'shinkan', 'Aktive Mitglieder', 'Anzahl aktiver Vereinsmitglieder', 'org', 'count', 'never', 25, 'club'),
('ai_calls', 'shinkan', 'KI-Aufrufe', 'KI-Aufrufe pro Monat (Suggest, Regenerate, Planung)', 'ai', 'count', 'monthly', 0, 'club'),
('ai_pipeline', 'shinkan', 'KI-Pipeline', 'Erweiterte KI-Batch-Pipelines', 'ai', 'boolean', 'never', 0, 'club'),
('wiki_import', 'shinkan', 'Wiki-Import', 'MediaWiki-Import (Plattform)', 'integration', 'boolean', 'never', 0, 'portal'),
('data_export', 'shinkan', 'Daten-Export', 'Export-Funktionen', 'integration', 'boolean', 'never', 0, 'club')
ON CONFLICT (id) DO NOTHING;
-- ── 5. Seed: Pläne ──────────────────────────────────────────────────────────
INSERT INTO club_plans (id, name, description, sort_order, active)
VALUES
('free', 'Free', 'Einstieg für Vereine', 0, true),
('verein_starter', 'Verein Starter', 'Erweiterte Kontingente', 10, true),
('verein_pro', 'Verein Pro', 'Hohe Limits und KI-Kontingent', 20, true),
('pilot', 'Pilot', 'Pilotverein mit großzügigen Limits', 5, true)
ON CONFLICT (id) DO NOTHING;
-- Plan-Limits: free
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
SELECT 'free', f.id,
CASE f.id
WHEN 'exercises' THEN 100
WHEN 'exercise_media' THEN 20
WHEN 'training_units' THEN 40
WHEN 'training_programs' THEN 5
WHEN 'training_groups' THEN 10
WHEN 'active_members' THEN 25
WHEN 'ai_calls' THEN 0
WHEN 'ai_pipeline' THEN 0
WHEN 'wiki_import' THEN 0
WHEN 'data_export' THEN 0
END
FROM features f
WHERE f.app = 'shinkan'
ON CONFLICT (plan_id, feature_id) DO NOTHING;
-- Plan-Limits: verein_starter
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
SELECT 'verein_starter', f.id,
CASE f.id
WHEN 'exercises' THEN 500
WHEN 'exercise_media' THEN 80
WHEN 'training_units' THEN 200
WHEN 'training_programs' THEN 30
WHEN 'training_groups' THEN 30
WHEN 'active_members' THEN 80
WHEN 'ai_calls' THEN 30
WHEN 'ai_pipeline' THEN 0
WHEN 'wiki_import' THEN 0
WHEN 'data_export' THEN 1
END
FROM features f
WHERE f.app = 'shinkan'
ON CONFLICT (plan_id, feature_id) DO NOTHING;
-- Plan-Limits: verein_pro (NULL = unbegrenzt wo sinnvoll)
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
SELECT 'verein_pro', f.id,
CASE f.id
WHEN 'exercises' THEN NULL
WHEN 'exercise_media' THEN 300
WHEN 'training_units' THEN NULL
WHEN 'training_programs' THEN NULL
WHEN 'training_groups' THEN NULL
WHEN 'active_members' THEN NULL
WHEN 'ai_calls' THEN 200
WHEN 'ai_pipeline' THEN 1
WHEN 'wiki_import' THEN 0
WHEN 'data_export' THEN 1
END
FROM features f
WHERE f.app = 'shinkan'
ON CONFLICT (plan_id, feature_id) DO NOTHING;
-- Plan-Limits: pilot
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
SELECT 'pilot', f.id,
CASE f.id
WHEN 'exercises' THEN NULL
WHEN 'exercise_media' THEN NULL
WHEN 'training_units' THEN NULL
WHEN 'training_programs' THEN NULL
WHEN 'training_groups' THEN NULL
WHEN 'active_members' THEN NULL
WHEN 'ai_calls' THEN 100
WHEN 'ai_pipeline' THEN 1
WHEN 'wiki_import' THEN 0
WHEN 'data_export' THEN 1
END
FROM features f
WHERE f.app = 'shinkan'
ON CONFLICT (plan_id, feature_id) DO NOTHING;
-- ── 6. Backfill: bestehende Vereine → Plan free ───────────────────────────
INSERT INTO club_subscriptions (club_id, plan_id, status)
SELECT c.id, 'free', 'active'
FROM clubs c
WHERE NOT EXISTS (
SELECT 1 FROM club_subscriptions cs WHERE cs.club_id = c.id
);

View File

@ -0,0 +1,43 @@
-- Migration 079: Planungs-KI Phase F — Stufenspezifikation (Prompt in ai_prompts, nicht im Code)
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, output_schema, is_system_default, default_template, active, sort_order
)
SELECT
'planning_progression_stage_spec',
'Progressions-Roadmap Stufenspezifikation',
'Phase C: Belastungsprofil, Übungstyp und Erfolgskriterien je Major Step.',
$t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Major Steps: {{major_steps_json}}
Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug).
Antworte NUR mit JSON:
{
"stage_specs": [
{
"major_step_index": 0,
"learning_goal": "",
"load_profile": ["koordination", "gleichgewicht"],
"exercise_type": "kihon_einzel",
"success_criteria": [""],
"anti_patterns": [""]
}
]
}$t$,
'training',
'json',
'{"type":"object","properties":{"stage_specs":{"type":"array"}}}'::jsonb,
true,
NULL,
true,
16
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_stage_spec');
UPDATE ai_prompts SET default_template = template
WHERE slug = 'planning_progression_stage_spec'
AND (default_template IS NULL OR TRIM(default_template) = '');

View File

@ -0,0 +1,225 @@
-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1)
-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py).
-- Voraussetzung: Migration 078 (features.id TEXT). Kein FK auf features — vermeidet
-- Startup-Abbruch wenn 078 noch aussteht oder features-Schema driftet (001 vs v9c).
DO $migration$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type'
) THEN
RAISE EXCEPTION
'Migration 079: features-Tabelle nicht v9c (limit_type fehlt). Zuerst 078_club_features_and_plans anwenden.';
END IF;
END
$migration$;
CREATE TABLE IF NOT EXISTS capabilities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
domain TEXT NOT NULL,
min_account_state TEXT NOT NULL DEFAULT 'active_member'
CHECK (min_account_state IN (
'unverified', 'verified_pending_club', 'active_member', 'platform_admin'
)),
linked_feature_id TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_capabilities_domain ON capabilities(domain) WHERE active = true;
CREATE TABLE IF NOT EXISTS club_role_capability_grants (
role_code TEXT NOT NULL,
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
PRIMARY KEY (role_code, capability_id)
);
CREATE INDEX IF NOT EXISTS idx_club_role_cap_grants_cap ON club_role_capability_grants(capability_id);
CREATE TABLE IF NOT EXISTS portal_role_capability_grants (
portal_role TEXT NOT NULL,
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
PRIMARY KEY (portal_role, capability_id)
);
-- ── Seed: Capabilities (v1 Katalog §5) ───────────────────────────────────────
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) VALUES
('account.settings.read', 'Einstellungen lesen', 'account', 'unverified', NULL),
('account.settings.update', 'Einstellungen ändern', 'account', 'unverified', NULL),
('account.password.change', 'Passwort ändern', 'account', 'unverified', NULL),
('account.resend_verification', 'Verifizierung erneut senden', 'account', 'unverified', NULL),
('club.directory.read', 'Vereinsverzeichnis', 'club', 'verified_pending_club', NULL),
('club.join_request.create', 'Vereinsbeitritt beantragen', 'club', 'verified_pending_club', NULL),
('club.join_request.withdraw', 'Beitrittsantrag zurückziehen', 'club', 'verified_pending_club', NULL),
('club.join_request.read_own', 'Eigene Beitrittsanträge', 'club', 'verified_pending_club', NULL),
('org.club.read', 'Vereine lesen', 'org', 'active_member', NULL),
('org.club.create', 'Verein anlegen', 'org', 'platform_admin', NULL),
('org.club.update', 'Verein bearbeiten', 'org', 'active_member', NULL),
('org.club.delete', 'Verein löschen', 'org', 'platform_admin', NULL),
('org.structure.manage', 'Vereinsstruktur verwalten', 'org', 'active_member', 'training_groups'),
('org.members.read', 'Mitgliederliste', 'org', 'active_member', NULL),
('org.members.manage', 'Mitglieder verwalten', 'org', 'active_member', 'active_members'),
('org.members.directory', 'Mitglieder-Verzeichnis', 'org', 'active_member', NULL),
('org.join_request.review', 'Beitrittsanträge prüfen', 'org', 'active_member', NULL),
('org.inbox.read', 'Posteingang', 'org', 'active_member', NULL),
('exercises.read', 'Übungen lesen', 'exercises', 'active_member', NULL),
('exercises.create', 'Übung anlegen', 'exercises', 'active_member', 'exercises'),
('exercises.update', 'Übung bearbeiten', 'exercises', 'active_member', NULL),
('exercises.delete', 'Übung löschen', 'exercises', 'active_member', NULL),
('exercises.bulk_metadata', 'Übungen Stapel-Metadaten', 'exercises', 'active_member', NULL),
('exercises.ai.suggest', 'KI-Vorschlag Übung', 'exercises', 'active_member', 'ai_calls'),
('exercises.ai.regenerate', 'KI neu generieren', 'exercises', 'active_member', 'ai_calls'),
('exercises.media.read', 'Übungsmedien lesen', 'exercises', 'active_member', NULL),
('exercises.media.upload', 'Übungsmedien hochladen', 'exercises', 'active_member', 'exercise_media'),
('exercises.variants.manage', 'Übungsvarianten', 'exercises', 'active_member', NULL),
('media.library.read', 'Medienbibliothek lesen', 'media', 'active_member', NULL),
('media.library.upload', 'Medienbibliothek Upload', 'media', 'active_member', 'exercise_media'),
('media.library.update', 'Medienbibliothek bearbeiten', 'media', 'active_member', NULL),
('media.library.lifecycle', 'Medien-Lifecycle', 'media', 'active_member', NULL),
('media.rights.declare', 'Medienrechte erklären', 'media', 'active_member', NULL),
('media.admin.rights_review', 'Medienrechte Review (Plattform)', 'media', 'platform_admin', NULL),
('modules.read', 'Trainingsmodule lesen', 'modules', 'active_member', NULL),
('modules.create', 'Trainingsmodul anlegen', 'modules', 'active_member', 'training_programs'),
('modules.update', 'Trainingsmodul bearbeiten', 'modules', 'active_member', NULL),
('modules.delete', 'Trainingsmodul löschen', 'modules', 'active_member', NULL),
('framework.read', 'Rahmenprogramme lesen', 'framework', 'active_member', NULL),
('framework.create', 'Rahmenprogramm anlegen', 'framework', 'active_member', 'training_programs'),
('framework.update', 'Rahmenprogramm bearbeiten', 'framework', 'active_member', NULL),
('framework.delete', 'Rahmenprogramm löschen', 'framework', 'active_member', NULL),
('plan_templates.read', 'Planungsvorlagen lesen', 'planning', 'active_member', NULL),
('plan_templates.manage', 'Planungsvorlagen verwalten', 'planning', 'active_member', NULL),
('progression.read', 'Progressionspfade lesen', 'progression', 'active_member', NULL),
('progression.manage', 'Progressionspfade verwalten', 'progression', 'active_member', NULL),
('planning.calendar.read', 'Planungskalender lesen', 'planning', 'active_member', NULL),
('planning.units.create', 'Trainingseinheit anlegen', 'planning', 'active_member', 'training_units'),
('planning.units.update', 'Trainingseinheit bearbeiten', 'planning', 'active_member', NULL),
('planning.units.delete', 'Trainingseinheit löschen', 'planning', 'active_member', NULL),
('planning.units.run', 'Training durchführen', 'planning', 'active_member', NULL),
('planning.coach.execute', 'Coach ausführen', 'planning', 'active_member', NULL),
('planning.ai.suggest', 'Planungs-KI Suggest', 'planning', 'active_member', 'ai_calls'),
('planning.ai.progression_path', 'Planungs-KI Progressionspfad', 'planning', 'active_member', 'ai_calls'),
('skills.catalog.read', 'Fähigkeitenkatalog', 'skills', 'active_member', NULL),
('skills.discovery.read', 'Fähigkeiten-Discovery', 'skills', 'active_member', NULL),
('skill_profiles.read', 'Skill-Profile lesen', 'skills', 'active_member', NULL),
('governance.content_report.create', 'Inhalt melden', 'governance', 'active_member', NULL),
('governance.content_report.review', 'Meldungen prüfen', 'governance', 'active_member', NULL),
('platform.admin.access', 'Plattform-Admin-Bereich', 'platform', 'platform_admin', NULL),
('platform.users.manage', 'Nutzer verwalten', 'platform', 'platform_admin', NULL),
('platform.catalogs.manage', 'Kataloge verwalten', 'platform', 'platform_admin', NULL),
('platform.maturity_models.manage', 'Reifegradmodelle', 'platform', 'platform_admin', NULL),
('platform.wiki_import.execute', 'Wiki-Import', 'platform', 'platform_admin', 'wiki_import'),
('platform.ai_prompts.manage', 'KI-Prompts verwalten', 'platform', 'platform_admin', NULL),
('platform.exercise_enrichment.execute', 'Übungs-Anreicherung KI', 'platform', 'platform_admin', 'ai_calls'),
('platform.user_content.moderate', 'Nutzer-Inhalte moderieren', 'platform', 'platform_admin', NULL),
('platform.legal_documents.manage', 'Rechtstexte verwalten', 'platform', 'platform_admin', NULL),
('platform.media_storage.manage', 'Medienspeicher verwalten', 'platform', 'platform_admin', NULL),
('platform.club_creation.approve', 'Vereinsgründung freigeben', 'platform', 'platform_admin', NULL)
ON CONFLICT (id) DO NOTHING;
-- ── Vereinsrollen-Grants (§6 — nur eingeschränkte Capabilities) ─────────────
-- Konvention: keine Grant-Zeile = alle aktiven Mitglieder (min_account_state reicht).
INSERT INTO club_role_capability_grants (role_code, capability_id)
SELECT r.role_code, c.id
FROM (VALUES
('club_admin', 'org.structure.manage'),
('division_lead', 'org.structure.manage'),
('club_admin', 'org.members.manage'),
('club_admin', 'org.join_request.review'),
('club_admin', 'org.inbox.read'),
('club_admin', 'exercises.create'),
('trainer', 'exercises.create'),
('content_editor', 'exercises.create'),
('division_lead', 'exercises.create'),
('club_admin', 'exercises.update'),
('trainer', 'exercises.update'),
('content_editor', 'exercises.update'),
('division_lead', 'exercises.update'),
('club_admin', 'exercises.delete'),
('club_admin', 'exercises.bulk_metadata'),
('content_editor', 'exercises.bulk_metadata'),
('club_admin', 'exercises.ai.suggest'),
('trainer', 'exercises.ai.suggest'),
('content_editor', 'exercises.ai.suggest'),
('division_lead', 'exercises.ai.suggest'),
('club_admin', 'exercises.ai.regenerate'),
('trainer', 'exercises.ai.regenerate'),
('content_editor', 'exercises.ai.regenerate'),
('division_lead', 'exercises.ai.regenerate'),
('club_admin', 'exercises.media.upload'),
('trainer', 'exercises.media.upload'),
('content_editor', 'exercises.media.upload'),
('club_admin', 'exercises.variants.manage'),
('trainer', 'exercises.variants.manage'),
('content_editor', 'exercises.variants.manage'),
('club_admin', 'media.library.upload'),
('trainer', 'media.library.upload'),
('content_editor', 'media.library.upload'),
('club_admin', 'media.library.update'),
('trainer', 'media.library.update'),
('content_editor', 'media.library.update'),
('club_admin', 'media.library.lifecycle'),
('trainer', 'media.library.lifecycle'),
('club_admin', 'media.rights.declare'),
('trainer', 'media.rights.declare'),
('club_admin', 'modules.create'),
('trainer', 'modules.create'),
('content_editor', 'modules.create'),
('club_admin', 'modules.update'),
('trainer', 'modules.update'),
('content_editor', 'modules.update'),
('club_admin', 'modules.delete'),
('club_admin', 'framework.create'),
('trainer', 'framework.create'),
('club_admin', 'framework.update'),
('trainer', 'framework.update'),
('club_admin', 'framework.delete'),
('club_admin', 'plan_templates.manage'),
('trainer', 'plan_templates.manage'),
('club_admin', 'progression.manage'),
('trainer', 'progression.manage'),
('content_editor', 'progression.manage'),
('club_admin', 'planning.units.create'),
('trainer', 'planning.units.create'),
('division_lead', 'planning.units.create'),
('club_admin', 'planning.units.update'),
('trainer', 'planning.units.update'),
('division_lead', 'planning.units.update'),
('club_admin', 'planning.units.delete'),
('trainer', 'planning.units.delete'),
('club_admin', 'planning.units.run'),
('trainer', 'planning.units.run'),
('division_lead', 'planning.units.run'),
('club_admin', 'planning.coach.execute'),
('trainer', 'planning.coach.execute'),
('club_admin', 'planning.ai.suggest'),
('trainer', 'planning.ai.suggest'),
('division_lead', 'planning.ai.suggest'),
('club_admin', 'planning.ai.progression_path'),
('trainer', 'planning.ai.progression_path'),
('division_lead', 'planning.ai.progression_path'),
('club_admin', 'skills.discovery.read'),
('trainer', 'skills.discovery.read'),
('content_editor', 'skills.discovery.read'),
('club_admin', 'governance.content_report.review')
) AS r(role_code, cap_id)
JOIN capabilities c ON c.id = r.cap_id
ON CONFLICT DO NOTHING;
-- org.club.update: club_admin (zusätzlich zu platform_admin via Bypass)
INSERT INTO club_role_capability_grants (role_code, capability_id)
VALUES ('club_admin', 'org.club.update')
ON CONFLICT DO NOTHING;
-- ── Portal-Rollen ───────────────────────────────────────────────────────────
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
SELECT 'admin', id FROM capabilities WHERE id = 'platform.admin.access'
ON CONFLICT DO NOTHING;
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
SELECT 'superadmin', id FROM capabilities WHERE domain = 'platform'
ON CONFLICT DO NOTHING;

View 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;

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

@ -0,0 +1,36 @@
-- Migration 082: Plattform-/Profil-Ausnahmen vom Vereins-Kontingent (M5+)
-- Superadmin & konfigurierbare Rollen/Profile verbrauchen kein club_feature_usage.
CREATE TABLE IF NOT EXISTS platform_role_club_feature_exemptions (
id SERIAL PRIMARY KEY,
portal_role TEXT NOT NULL,
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_role_club_feat_exempt
ON platform_role_club_feature_exemptions (portal_role, COALESCE(feature_id, '*'));
CREATE TABLE IF NOT EXISTS profile_club_feature_exemptions (
id SERIAL PRIMARY KEY,
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
feature_id TEXT REFERENCES features(id) ON DELETE CASCADE,
reason TEXT,
set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_profile_club_feat_exempt
ON profile_club_feature_exemptions (profile_id, COALESCE(feature_id, '*'));
CREATE INDEX IF NOT EXISTS idx_profile_club_feat_exempt_profile
ON profile_club_feature_exemptions (profile_id);
-- Superadmin: alle Vereins-Features ohne Kontingent-Verbrauch
INSERT INTO platform_role_club_feature_exemptions (portal_role, feature_id, note)
SELECT 'superadmin', NULL, 'Plattform-Administrator: kein Vereins-Kontingent'
WHERE NOT EXISTS (
SELECT 1 FROM platform_role_club_feature_exemptions
WHERE portal_role = 'superadmin' AND feature_id IS NULL
);

View File

@ -0,0 +1,103 @@
-- Migration 083: Vereins-Kontingent-Bypass über Capability-System (kein Parallel-Schema)
-- Ersetzt platform_role_club_feature_exemptions / profile_club_feature_exemptions aus 082.
-- Einzelprofil-Grants (ergänzt portal_role_capability_grants)
CREATE TABLE IF NOT EXISTS profile_capability_grants (
profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
reason TEXT,
granted_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (profile_id, capability_id)
);
CREATE INDEX IF NOT EXISTS idx_profile_capability_grants_cap
ON profile_capability_grants(capability_id);
-- Bypass-Capabilities (CAPABILITY_CATALOG — konfigurierbar via portal/profile grants)
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
VALUES
(
'platform.club_quota.bypass',
'Vereins-Kontingent umgehen (alle Features)',
'platform',
'platform_admin',
NULL
)
ON CONFLICT (id) DO NOTHING;
-- Superadmin: alle Plattform-Capabilities inkl. bypass (079-Seed deckt domain=platform ab)
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
SELECT 'superadmin', 'platform.club_quota.bypass'
WHERE NOT EXISTS (
SELECT 1 FROM portal_role_capability_grants
WHERE portal_role = 'superadmin' AND capability_id = 'platform.club_quota.bypass'
);
-- ── Daten aus 082 übernehmen (falls vorhanden) ─────────────────────────────
DO $migrate082$
DECLARE
r RECORD;
cap_id TEXT;
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'platform_role_club_feature_exemptions'
) THEN
RETURN;
END IF;
FOR r IN
SELECT portal_role, feature_id, note
FROM platform_role_club_feature_exemptions
LOOP
IF r.feature_id IS NULL THEN
cap_id := 'platform.club_quota.bypass';
ELSE
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
VALUES (
cap_id,
'Vereins-Kontingent umgehen: ' || r.feature_id,
'quota_bypass',
'active_member',
r.feature_id
)
ON CONFLICT (id) DO NOTHING;
END IF;
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
VALUES (lower(trim(r.portal_role)), cap_id)
ON CONFLICT DO NOTHING;
END LOOP;
FOR r IN
SELECT profile_id, feature_id, reason, set_by_profile_id
FROM profile_club_feature_exemptions
LOOP
IF r.feature_id IS NULL THEN
cap_id := 'platform.club_quota.bypass';
ELSE
cap_id := 'platform.club_quota.bypass.' || r.feature_id;
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id)
VALUES (
cap_id,
'Vereins-Kontingent umgehen: ' || r.feature_id,
'quota_bypass',
'active_member',
r.feature_id
)
ON CONFLICT (id) DO NOTHING;
END IF;
INSERT INTO profile_capability_grants (
profile_id, capability_id, reason, granted_by_profile_id
)
VALUES (r.profile_id, cap_id, r.reason, r.set_by_profile_id)
ON CONFLICT DO NOTHING;
END LOOP;
DROP TABLE IF EXISTS profile_club_feature_exemptions;
DROP TABLE IF EXISTS platform_role_club_feature_exemptions;
END
$migrate082$;

View File

@ -0,0 +1,15 @@
-- Migration 084: Modul-Registrierung für Rechte & Kontingente (Registry-first)
-- capabilities/features mit module=NULL = Legacy-Katalog-Seed (nicht in Admin-Matrix).
-- module IS NOT NULL = vom Modul bei Implementierung registriert.
ALTER TABLE capabilities
ADD COLUMN IF NOT EXISTS module TEXT;
ALTER TABLE features
ADD COLUMN IF NOT EXISTS module TEXT;
CREATE INDEX IF NOT EXISTS idx_capabilities_module
ON capabilities(module) WHERE module IS NOT NULL AND active = true;
CREATE INDEX IF NOT EXISTS idx_features_module
ON features(module) WHERE module IS NOT NULL AND active = true;

View File

@ -0,0 +1,181 @@
-- Migration 085: Planungskontext in Übungs-KI-Prompts (Phase D)
-- Platzhalter: {{planning_context_json}}, {{#has_planning_context}} … {{/has_planning_context}}
UPDATE ai_prompts
SET template = $s$Du bist Assistent fuer Kampfsport-Trainer.
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
Anforderungen:
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
- Sachlich, auf Deutsch
Uebung: {{exercise_title}}
Fokuskontext: {{exercise_focus_area}}
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
{{#has_planning_context}}
Planungskontext (JSON Einordnung in Trainingsplan oder Progressionspfad):
{{planning_context_json}}
{{/has_planning_context}}
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$,
default_template = $s$Du bist Assistent fuer Kampfsport-Trainer.
Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
Anforderungen:
- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
- Sachlich, auf Deutsch
Uebung: {{exercise_title}}
Fokuskontext: {{exercise_focus_area}}
Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
{{#has_planning_context}}
Planungskontext (JSON Einordnung in Trainingsplan oder Progressionspfad):
{{planning_context_json}}
{{/has_planning_context}}
Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$
WHERE slug = 'exercise_summary';
UPDATE ai_prompts
SET template = $j$Du bist Assistent fuer Kampfsport-Trainer.
Ordne diese Uebung dem globalen Skill-Katalog zu.
Daten zur Uebung:
Titel: {{exercise_title}}
Fokuskontext (optional): {{exercise_focus_area}}
Ziel (gekuerzt_plain): {{exercise_goal}}
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
{{#has_planning_context}}
Planungskontext (JSON):
{{planning_context_json}}
{{/has_planning_context}}
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs keine anderen IDs verwenden):
{{skills_catalog}}
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
- skill_id: ganze Zahl aus der Liste
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
- target_level: derselbe Wertvorrat
- intensity: eines von niedrig, mittel, hoch
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
Beispielformat:
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
Wenn nichts gut passt, antworte mit [].$j$,
default_template = $j$Du bist Assistent fuer Kampfsport-Trainer.
Ordne diese Uebung dem globalen Skill-Katalog zu.
Daten zur Uebung:
Titel: {{exercise_title}}
Fokuskontext (optional): {{exercise_focus_area}}
Ziel (gekuerzt_plain): {{exercise_goal}}
Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
{{#has_planning_context}}
Planungskontext (JSON):
{{planning_context_json}}
{{/has_planning_context}}
Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs keine anderen IDs verwenden):
{{skills_catalog}}
Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
- skill_id: ganze Zahl aus der Liste
- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
- target_level: derselbe Wertvorrat
- intensity: eines von niedrig, mittel, hoch
- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
Beispielformat:
[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
Wenn nichts gut passt, antworte mit [].$j$
WHERE slug = 'exercise_skill_suggestions';
UPDATE ai_prompts
SET template = $t$Du bist Assistent fuer Kampfsport-Trainer.
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
Wichtig: Texte sollen praezise und nachvollziehbar bleiben keine Fuellsaetze, keine Wiederholungen, kein Marketing.
Stil:
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
- Ziel: 13 kurze Absaetze (Kern des Trainingsziels)
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) sonst leerer String
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps knapp, Stichpunkte oder kurze Absaetze
Format (HTML fuer Rich-Text-Editor):
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
- Keine Ueberschriften (h1h6), keine Tabellen, kein Markdown, keine Code-Fences
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
Eingabe:
Titel: {{exercise_title}}
Fokuskontext: {{exercise_focus_area}}
Ziel (Plaintext, Ausgang): {{exercise_goal}}
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
{{#has_planning_context}}
Planungskontext (JSON):
{{planning_context_json}}
{{/has_planning_context}}
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
{
"goal": "<p>…</p>",
"execution": "<ol><li>…</li></ol>",
"preparation": "<p>…</p> oder \"\"",
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
}
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
default_template = $t$Du bist Assistent fuer Kampfsport-Trainer.
Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
Wichtig: Texte sollen praezise und nachvollziehbar bleiben keine Fuellsaetze, keine Wiederholungen, kein Marketing.
Stil:
- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
- Ziel: 13 kurze Absaetze (Kern des Trainingsziels)
- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) sonst leerer String
- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps knapp, Stichpunkte oder kurze Absaetze
Format (HTML fuer Rich-Text-Editor):
- Erlaubt: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
- Keine Ueberschriften (h1h6), keine Tabellen, kein Markdown, keine Code-Fences
- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
Eingabe:
Titel: {{exercise_title}}
Fokuskontext: {{exercise_focus_area}}
Ziel (Plaintext, Ausgang): {{exercise_goal}}
Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
{{#has_planning_context}}
Planungskontext (JSON):
{{planning_context_json}}
{{/has_planning_context}}
Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
{
"goal": "<p>…</p>",
"execution": "<ol><li>…</li></ol>",
"preparation": "<p>…</p> oder \"\"",
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
}
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$
WHERE slug = 'exercise_instruction_rewrite';

View File

@ -0,0 +1,52 @@
-- Migration 087: Planungs-KI — LLM Start/Ziel-Extraktion aus Trainer-Anfrage (Alternative zu Regex)
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, output_schema, is_system_default, default_template, active, sort_order
)
SELECT
'planning_progression_start_target',
'Progressions-Roadmap Start/Ziel-Extraktion',
'Versteht die Trainer-Anfrage und formuliert dedizierte Ausgangslage, Zielzustand und Ergänzungen (ohne Gruppen-Tracking).',
$t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen didaktischen Progressionsgraphen.
Trainer-Anfrage (Ursprungstext):
{{goal_query}}
Semantic Brief (heuristisch): {{semantic_brief_json}}
Bereits vom Trainer eingegebene Ergänzungen (falls vorhanden): {{user_notes}}
Aufgabe:
1. **primary_topic** Kern-Thema/Technik in kurzer, präziser Bezeichnung (z. B. Kumite Beinarbeit, Mae Geri).
2. **start_situation** Ausgangslage in eigenen Worten: Was kann der Athlet/die Gruppe *jetzt* (laut Anfrage oder sinnvoll ableitbar)? Konkret, beobachtbar, ohne Gruppenanalyse aus der Datenbank.
3. **target_state** Zielzustand in eigenen Worten: Was soll am Ende der Progression erreicht sein? Konkret, didaktisch nutzbar.
4. **roadmap_notes** Ergänzungen aus dem Ursprungstext: Fokus, Kontext (z. B. Kumite), besondere Anforderungen, Einschränkungen, die der Trainer erwähnt hat oder die für die Roadmap relevant sind. Nicht wiederholen, was bereits in start_situation/target_state steht.
5. **extraction_notes** Kurz (12 Sätze): Was war explizit vs. abgeleitet? Wo war die Anfrage unklar?
Regeln:
- Keine Gruppenanalyse nur das, was aus dem Text hervorgeht oder didaktisch naheliegend formuliert ist.
- Formuliere start_situation und target_state **eigenständig und verständlich**, nicht nur Textfragmente kopieren.
- Bei von bis : Start und Ziel aus diesem Bogen schärfen und präzise beschreiben.
- Bei nur einem Thema ohne Bogen: start_situation und target_state didaktisch sinnvoll formulieren oder leer lassen, wenn nicht ableitbar dann in extraction_notes erklären.
- Antworte NUR mit JSON.
{
"primary_topic": "",
"start_situation": "",
"target_state": "",
"roadmap_notes": "",
"extraction_notes": ""
}$t$,
'training',
'json',
'{"type":"object","properties":{"primary_topic":{"type":"string"},"start_situation":{"type":"string"},"target_state":{"type":"string"},"roadmap_notes":{"type":"string"},"extraction_notes":{"type":"string"}}}'::jsonb,
true,
NULL,
true,
13
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_progression_start_target');
UPDATE ai_prompts SET default_template = template
WHERE slug = 'planning_progression_start_target'
AND (default_template IS NULL OR TRIM(default_template) = '');

View File

@ -0,0 +1,217 @@
"""
Planungs-KI Phase D: strukturierter Planungskontext für POST /exercises/ai/suggest.
Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instructions) injiziert.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Mapping, Optional
_MAX_JSON_CHARS = 6000
_MAX_STRING = 800
def compact_planning_context_json(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def _trim_str(val: Any, *, limit: int = _MAX_STRING) -> Optional[str]:
if val is None:
return None
s = str(val).strip()
if not s:
return None
if len(s) > limit:
return s[: limit - 1] + ""
return s
def sanitize_planning_context_for_ai(ctx: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
"""Reduziert Client-Payload auf prompt-taugliche, begrenzte Felder."""
if not ctx:
return {}
out: Dict[str, Any] = {}
for key, val in dict(ctx).items():
if val is None:
continue
k = str(key).strip()
if not k:
continue
if isinstance(val, str):
t = _trim_str(val)
if t:
out[k] = t
elif isinstance(val, (int, float, bool)):
out[k] = val
elif isinstance(val, list):
items = []
for item in val[:12]:
if isinstance(item, str):
t = _trim_str(item, limit=200)
if t:
items.append(t)
elif isinstance(item, (int, float, bool)):
items.append(item)
elif isinstance(item, dict):
sub = sanitize_planning_context_for_ai(item)
if sub:
items.append(sub)
if items:
out[k] = items
elif isinstance(val, dict):
sub = sanitize_planning_context_for_ai(val)
if sub:
out[k] = sub
raw = compact_planning_context_json(out)
if len(raw) > _MAX_JSON_CHARS:
out["truncated"] = True
out.pop("path_steps_preview", None)
raw = compact_planning_context_json(out)
if len(raw) > _MAX_JSON_CHARS:
return {"source": out.get("source"), "truncated": True, "goal_query": out.get("goal_query")}
return out
def planning_context_prompt_variables(
planning_context: Optional[Mapping[str, Any]],
) -> Dict[str, str]:
cleaned = sanitize_planning_context_for_ai(planning_context)
if not cleaned:
return {"planning_context_json": "-", "has_planning_context": ""}
return {
"planning_context_json": compact_planning_context_json(cleaned),
"has_planning_context": "true",
}
def build_progression_gap_snapshot(
*,
goal_analysis: Optional[Mapping[str, Any]] = None,
resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
"""Kompakter Roadmap-Kontext für Lücken-Übungen (Start, Ziel, Stufe, Fähigkeiten-Hinweise)."""
ga = dict(goal_analysis or {})
rs = dict(resolved_structured or {})
spec = dict(stage_spec or {})
brief = dict(semantic_brief or {})
start = _trim_str(rs.get("start_situation") or ga.get("start_assumption"))
target = _trim_str(rs.get("target_state") or ga.get("target_state"))
notes = _trim_str(rs.get("roadmap_notes"))
topic = _trim_str(ga.get("primary_topic") or brief.get("primary_topic"))
skill_hints: List[str] = []
for item in (brief.get("must_phrases") or [])[:4]:
t = _trim_str(item, limit=120)
if t:
skill_hints.append(t)
arc = brief.get("development_arc")
if isinstance(arc, list) and arc:
skill_hints.append(f"Entwicklungsbogen: {''.join(str(x) for x in arc[:5])}")
success_path = [
_trim_str(x, limit=200)
for x in (ga.get("success_criteria") or [])
if _trim_str(x, limit=200)
][:4]
stage_success = [
_trim_str(x, limit=200)
for x in (spec.get("success_criteria") or [])
if _trim_str(x, limit=200)
][:4]
load_profile = [
_trim_str(x, limit=80)
for x in (spec.get("load_profile") or [])
if _trim_str(x, limit=80)
][:6]
anti_patterns = [
_trim_str(x, limit=200)
for x in (spec.get("anti_patterns") or [])
if _trim_str(x, limit=200)
][:3]
snap: Dict[str, Any] = {
"primary_topic": topic,
"start_situation": start,
"target_state": target,
"roadmap_notes": notes,
"stage_learning_goal": _trim_str(
spec.get("learning_goal"), limit=1200
),
"stage_phase": _trim_str(spec.get("phase")),
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
"stage_load_profile": load_profile or None,
"stage_success_criteria": stage_success or None,
"stage_anti_patterns": anti_patterns or None,
"path_success_criteria": success_path or None,
"skill_hints": skill_hints or None,
}
return {k: v for k, v in snap.items() if v is not None and v != "" and v != []}
def build_progression_path_gap_planning_context(
*,
goal_query: str,
primary_topic: Optional[str] = None,
progression_graph_id: Optional[int] = None,
offer: Optional[Mapping[str, Any]] = None,
neighbor_before: Optional[Mapping[str, Any]] = None,
neighbor_after: Optional[Mapping[str, Any]] = None,
path_step_count: int = 0,
major_step_count: Optional[int] = None,
roadmap_phase: Optional[str] = None,
roadmap_learning_goal: Optional[str] = None,
goal_analysis: Optional[Mapping[str, Any]] = None,
resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
offer = offer or {}
gap = offer.get("gap") if isinstance(offer.get("gap"), dict) else {}
major_idx = offer.get("roadmap_major_step_index")
if major_idx is None and isinstance(gap, dict):
major_idx = gap.get("roadmap_major_step_index")
ctx: Dict[str, Any] = {
"source": "progression_path_gap_fill",
"goal_query": _trim_str(goal_query, limit=2000),
"primary_topic": _trim_str(primary_topic),
"progression_graph_id": progression_graph_id,
"gap_source": _trim_str(offer.get("source")),
"gap_phase": _trim_str(offer.get("phase") or gap.get("expected_phase")),
"roadmap_major_step_index": major_idx,
"roadmap_phase": _trim_str(roadmap_phase or offer.get("phase")),
"roadmap_learning_goal": _trim_str(
roadmap_learning_goal or offer.get("title_hint") or gap.get("learning_goal"),
limit=1200,
),
"neighbor_before_title": _trim_str(
(neighbor_before or {}).get("title") or offer.get("from_title")
),
"neighbor_after_title": _trim_str(
(neighbor_after or {}).get("title") or offer.get("to_title")
),
"path_step_count": path_step_count,
"major_step_count": major_step_count,
}
snap = build_progression_gap_snapshot(
goal_analysis=goal_analysis,
resolved_structured=resolved_structured,
stage_spec=stage_spec,
semantic_brief=semantic_brief,
)
ctx.update(snap)
return sanitize_planning_context_for_ai(ctx)
__all__ = [
"build_progression_gap_snapshot",
"build_progression_path_gap_planning_context",
"compact_planning_context_json",
"planning_context_prompt_variables",
"sanitize_planning_context_for_ai",
]

View File

@ -12,7 +12,8 @@ from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_path_qa import find_step_pair_index
from planning_exercise_semantics import PlanningSemanticBrief
from planning_exercise_form_context import build_progression_gap_snapshot
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
@ -258,14 +259,97 @@ def collect_gap_fill_specs(
return specs[:5]
def build_gap_fill_goal_text(
*,
goal_query: str,
brief: PlanningSemanticBrief,
spec: Mapping[str, Any],
step_a: Optional[Mapping[str, Any]] = None,
step_b: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> str:
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
topic = (brief.primary_topic or "Technik").strip()
phase = spec.get("phase") or "vertiefung"
from_title = (step_a or {}).get("title") or spec.get("from_title") or "vorherigem Schritt"
to_title = (step_b or {}).get("title") or spec.get("to_title") or "nächstem Schritt"
arc = ", ".join(brief.development_arc or []) or "einstieg → grundlage → vertiefung → anwendung → perfektion"
snap = dict(roadmap_snapshot or {})
if not snap:
snap = build_progression_gap_snapshot(semantic_brief=brief_to_summary_dict(brief))
parts = [
f"Planungsziel (gesamter Pfad): {goal_query}",
f"Hauptthema: {snap.get('primary_topic') or topic}",
]
if snap.get("start_situation"):
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
if snap.get("target_state"):
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
if snap.get("roadmap_notes"):
parts.append(f"Ergänzender Kontext: {snap['roadmap_notes']}")
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
if stage_goal:
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
parts.extend(
[
f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}",
f"Erwarteter Entwicklungsbogen: {arc}",
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
]
)
if snap.get("stage_load_profile"):
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
if snap.get("stage_success_criteria"):
parts.append(
"Erfolgskriterien dieser Stufe: "
+ "; ".join(str(x) for x in snap["stage_success_criteria"][:4])
)
if snap.get("stage_anti_patterns"):
parts.append(
"Vermeiden: " + "; ".join(str(x) for x in snap["stage_anti_patterns"][:3])
)
if snap.get("skill_hints"):
parts.append(
"Fähigkeiten-/Fokus-Hinweise: "
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
)
if spec.get("rationale"):
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
if spec.get("sketch"):
parts.append(f"Skizze: {spec['sketch']}")
parts.append(
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
"Konkrete Durchführung, Ziel und Trainerhinweise ausformulieren."
)
return "\n\n".join(parts)[:8000]
def build_gap_fill_offer(
*,
spec: Mapping[str, Any],
steps: Sequence[Mapping[str, Any]],
goal_query: str = "",
brief: Optional[PlanningSemanticBrief] = None,
proposal: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
idx = int(spec.get("insert_after_index") or 0)
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
step_a = steps[idx] if idx < len(steps) else None
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
goal_for_ai = ""
if brief and goal_query:
goal_for_ai = build_gap_fill_goal_text(
goal_query=goal_query,
brief=brief,
spec=spec,
step_a=step_a,
step_b=step_b,
roadmap_snapshot=roadmap_snapshot,
)
ctx_preview = dict(roadmap_snapshot) if roadmap_snapshot else None
offer: Dict[str, Any] = {
"offer_id": offer_id,
"source": spec.get("source"),
@ -273,11 +357,15 @@ def build_gap_fill_offer(
"replace_step_index": spec.get("replace_step_index"),
"title_hint": spec.get("title_hint"),
"sketch": spec.get("sketch"),
"goal_for_ai": goal_for_ai or spec.get("sketch"),
"context_preview": ctx_preview,
"phase": spec.get("phase"),
"rationale": spec.get("rationale"),
"has_ai_payload": False,
"from_title": (steps[idx].get("title") if idx < len(steps) else None),
"to_title": (steps[idx + 1].get("title") if idx + 1 < len(steps) else None),
"from_title": (step_a or {}).get("title"),
"to_title": (step_b or {}).get("title"),
"primary_topic": (brief.primary_topic if brief else None),
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
}
if proposal:
offer["has_ai_payload"] = True
@ -298,6 +386,7 @@ def apply_gap_fill_after_qa(
include_ai_calls: bool = True,
max_ai_proposals: int = 3,
auto_insert_proposals: bool = False,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
@ -317,7 +406,14 @@ def apply_gap_fill_after_qa(
step_a = out[idx]
step_b = out[idx + 1]
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=None)
offer = build_gap_fill_offer(
spec=spec,
steps=out,
goal_query=goal_query,
brief=brief,
proposal=None,
roadmap_snapshot=roadmap_snapshot,
)
offers.append(offer)
continue
@ -338,7 +434,14 @@ def apply_gap_fill_after_qa(
sketch_hint=str(spec.get("sketch") or ""),
)
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=proposal)
offer = build_gap_fill_offer(
spec=spec,
steps=out,
goal_query=goal_query,
brief=brief,
proposal=proposal,
roadmap_snapshot=roadmap_snapshot,
)
offers.append(offer)
if proposal and auto_insert_proposals:
@ -389,6 +492,7 @@ def insert_ai_proposals_for_gaps(
__all__ = [
"apply_gap_fill_after_qa",
"build_gap_fill_goal_text",
"build_gap_fill_offer",
"collect_gap_fill_specs",
"insert_ai_proposals_for_gaps",

View File

@ -1,11 +1,12 @@
"""
Planungs-KI Phase C3/E: Pfad-Vorschläge für Progressionsgraphen.
Planungs-KI Phase C3/E/F: Pfad-Vorschläge für Progressionsgraphen.
Ziel-Freitext semantisch gewichtete Schritte Lücken/Brücken optional LLM-QA.
Legacy: retrieval-first. Phase F: optional Roadmap-Preview (ABC) parallel siehe
planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
@ -19,9 +20,14 @@ from planning_exercise_path_qa import (
detect_path_gaps,
insert_bridge_exercises,
parse_llm_suggested_new_exercises,
strip_off_topic_steps_from_path,
try_llm_qa_progression_path,
)
from planning_exercise_path_ai_fill import apply_gap_fill_after_qa, collect_gap_fill_specs
from planning_exercise_path_ai_fill import (
apply_gap_fill_after_qa,
build_gap_fill_offer,
collect_gap_fill_specs,
)
from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_semantics import (
PlanningSemanticBrief,
@ -44,6 +50,21 @@ from planning_exercise_suggest import (
_normalize_query,
resolve_planning_exercise_intent,
)
from planning_exercise_form_context import build_progression_gap_snapshot
from planning_progression_roadmap import (
MajorStep,
ProgressionRoadmapContext,
RoadmapOverridePayload,
RoadmapStructuredInput,
StageSpecArtifact,
build_roadmap_unfilled_gap_specs,
progression_roadmap_to_api_dict,
resolve_step_exercise_kind_filter,
roadmap_context_from_override,
run_progression_roadmap_pipeline,
run_start_target_resolve_only,
stage_spec_retrieval_query,
)
from routers.training_planning import _has_planning_role
@ -55,10 +76,71 @@ class ProgressionPathSuggestRequest(BaseModel):
include_llm_path_qa: bool = True
include_path_reorder: bool = True
include_ai_gap_fill: bool = True
include_roadmap_preview: bool = False
include_llm_roadmap: bool = True
include_llm_start_target: bool = True
roadmap_first: bool = False
roadmap_only: bool = False
start_target_only: bool = False
roadmap_override: Optional[RoadmapOverridePayload] = None
start_situation: Optional[str] = Field(default=None, max_length=2000)
target_state: Optional[str] = Field(default=None, max_length=2000)
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None
def _roadmap_gap_snapshot_for_spec(
roadmap_ctx: Optional[ProgressionRoadmapContext],
spec: Mapping[str, Any],
*,
semantic_brief: PlanningSemanticBrief,
) -> Dict[str, Any]:
"""Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec)."""
major_idx = spec.get("roadmap_major_step_index")
stage_spec_dict: Optional[Dict[str, Any]] = None
if roadmap_ctx and major_idx is not None:
for s in roadmap_ctx.stage_specs or []:
if int(s.major_step_index) == int(major_idx):
stage_spec_dict = s.model_dump()
if roadmap_ctx.roadmap:
for m in roadmap_ctx.roadmap.major_steps:
if m.index == int(major_idx):
stage_spec_dict["phase"] = m.phase
break
break
ga = roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx and roadmap_ctx.goal_analysis else None
rs = (
roadmap_ctx.resolved_structured.model_dump()
if roadmap_ctx and roadmap_ctx.resolved_structured
else None
)
brief_summary = (
roadmap_ctx.semantic_brief
if roadmap_ctx and roadmap_ctx.semantic_brief
else brief_to_summary_dict(semantic_brief)
)
return build_progression_gap_snapshot(
goal_analysis=ga,
resolved_structured=rs,
stage_spec=stage_spec_dict,
semantic_brief=brief_summary,
)
def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Optional[RoadmapStructuredInput]:
start = (body.start_situation or "").strip() or None
target = (body.target_state or "").strip() or None
notes = (body.roadmap_notes or "").strip() or None
if not any([start, target, notes]):
return None
return RoadmapStructuredInput(
start_situation=start,
target_state=target,
roadmap_notes=notes,
)
def _pick_best_path_hit(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
@ -160,8 +242,12 @@ def _run_path_step_retrieval(
step_b: Optional[Dict[str, Any]] = None,
path_target_profile: Optional[PlanningTargetProfile] = None,
path_intent: Optional[str] = None,
step_query_override: Optional[str] = None,
step_phase_override: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
step_query = step_retrieval_query(semantic_brief, goal_query, step_index, max_steps)
step_query = step_query_override or step_retrieval_query(
semantic_brief, goal_query, step_index, max_steps
)
if bridge_mode and step_a and step_b:
phase = step_phase_for_index(semantic_brief, step_index, max_steps)
parts = [semantic_brief.primary_topic or semantic_brief.retrieval_query or goal_query]
@ -191,7 +277,8 @@ def _run_path_step_retrieval(
"has_planning_reference": bool(planned_ids or anchor_id or bridge_mode),
"semantic_brief": semantic_brief,
"retrieval_query": step_query,
"path_step_phase": step_phase_for_index(semantic_brief, step_index, max_steps),
"path_step_phase": step_phase_override
or step_phase_for_index(semantic_brief, step_index, max_steps),
}
pack = apply_progression_context_to_pack(
cur,
@ -322,6 +409,130 @@ def _make_bridge_search_fn(
return _bridge_search
def _annotate_roadmap_step(
step: Dict[str, Any],
*,
stage_spec: StageSpecArtifact,
major_step: Optional[MajorStep],
) -> Dict[str, Any]:
reasons = list(step.get("reasons") or [])
learning_goal = (stage_spec.learning_goal or "").strip()
if learning_goal:
roadmap_reason = f"Roadmap: {learning_goal[:120]}"
if roadmap_reason not in reasons:
reasons.insert(0, roadmap_reason)
step["reasons"] = reasons[:4]
step["roadmap_major_step_index"] = stage_spec.major_step_index
step["roadmap_phase"] = major_step.phase if major_step else None
step["roadmap_learning_goal"] = learning_goal or None
step["roadmap_match_source"] = "stage_spec"
return step
def _build_steps_roadmap_first(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
goal_query: str,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
path_target_profile: PlanningTargetProfile,
path_intent: str,
roadmap_ctx: ProgressionRoadmapContext,
) -> Tuple[List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
"""Retrieval pro stage_spec statt iterativem Pfad-Bau (Phase F3)."""
stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
if not stage_specs and roadmap_ctx.roadmap:
stage_specs = [
StageSpecArtifact(
major_step_index=m.index,
learning_goal=m.learning_goal,
)
for m in roadmap_ctx.roadmap.major_steps[:max_steps]
]
major_by_index: Dict[int, MajorStep] = {}
if roadmap_ctx.roadmap:
major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
used: Set[int] = set()
steps: List[Dict[str, Any]] = []
planned_ids: List[int] = []
anchor_id: Optional[int] = None
anchor_variant_id: Optional[int] = None
unfilled: List[Tuple[int, StageSpecArtifact]] = []
for step_index, stage_spec in enumerate(stage_specs):
major = major_by_index.get(stage_spec.major_step_index)
step_query = stage_spec_retrieval_query(
semantic_brief=semantic_brief,
goal_query=goal_query,
stage_spec=stage_spec,
major_step=major,
)
step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
hits, _, _, _ = _run_path_step_retrieval(
cur,
tenant=tenant,
goal_query=goal_query,
step_index=step_index,
max_steps=max_steps,
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
progression_graph_id=body.progression_graph_id,
include_llm_intent=body.include_llm_intent and step_index == 0,
exercise_kind_any=step_kind,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
step_query_override=step_query,
step_phase_override=major.phase if major else None,
)
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
if not hit and step_query != goal_query:
hits, _, _, _ = _run_path_step_retrieval(
cur,
tenant=tenant,
goal_query=goal_query,
step_index=step_index,
max_steps=max_steps,
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
progression_graph_id=body.progression_graph_id,
include_llm_intent=False,
exercise_kind_any=step_kind,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
step_query_override=goal_query,
step_phase_override=major.phase if major else None,
)
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
if not hit:
unfilled.append((step_index, stage_spec))
continue
step = _annotate_roadmap_step(
_hit_to_path_step(hit),
stage_spec=stage_spec,
major_step=major,
)
steps.append(step)
eid = int(step["exercise_id"])
used.add(eid)
planned_ids.append(eid)
anchor_id = eid
anchor_variant_id = step.get("variant_id")
return steps, unfilled
def suggest_progression_path(
cur,
*,
@ -344,6 +555,97 @@ def suggest_progression_path(
cur, goal_query, semantic_brief
)
roadmap_first = bool(body.roadmap_first)
roadmap_only = bool(body.roadmap_only)
start_target_only = bool(body.start_target_only)
include_roadmap = (
roadmap_first or body.include_roadmap_preview or roadmap_only or start_target_only
)
progression_roadmap: Optional[Dict[str, Any]] = None
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
roadmap_edited = False
roadmap_structured = _roadmap_structured_from_body(body)
if body.roadmap_override is not None:
try:
roadmap_ctx = roadmap_context_from_override(
goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
override=body.roadmap_override,
structured=roadmap_structured,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
progression_roadmap["roadmap_edited"] = True
roadmap_edited = True
max_steps = int(roadmap_ctx.max_steps)
roadmap_first = True
elif start_target_only:
roadmap_ctx = run_start_target_resolve_only(
goal_query,
semantic_brief=semantic_brief,
cur=cur,
include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured,
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
elif include_roadmap:
roadmap_ctx = run_progression_roadmap_pipeline(
goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
cur=cur,
include_llm_roadmap=body.include_llm_roadmap,
include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured,
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
if start_target_only:
return {
"goal_query": goal_query,
"max_steps_requested": max_steps,
"steps": [],
"step_count": 0,
"target_profile_summary": None,
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied,
"query_intent_summary": {},
"progression_graph_id": body.progression_graph_id,
"path_qa": None,
"gap_fill_offers": [],
"progression_roadmap": progression_roadmap,
"roadmap_first": False,
"roadmap_only": False,
"start_target_only": True,
"roadmap_edited": False,
"roadmap_unfilled_count": 0,
"retrieval_phase": "start_target_only",
}
if roadmap_only:
return {
"goal_query": goal_query,
"max_steps_requested": max_steps,
"steps": [],
"step_count": 0,
"target_profile_summary": None,
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied,
"query_intent_summary": {},
"progression_graph_id": body.progression_graph_id,
"path_qa": None,
"gap_fill_offers": [],
"progression_roadmap": progression_roadmap,
"roadmap_first": False,
"roadmap_only": True,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": 0,
"retrieval_phase": "roadmap_only",
}
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
cur,
goal_query=goal_query,
@ -351,41 +653,89 @@ def suggest_progression_path(
include_llm_intent=body.include_llm_intent,
)
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
roadmap_gap_offers: List[Dict[str, Any]] = []
used: Set[int] = set()
steps: List[Dict[str, Any]] = []
planned_ids: List[int] = []
anchor_id: Optional[int] = None
anchor_variant_id: Optional[int] = None
for step_index in range(max_steps):
hits, _tp, _qis, _intent = _run_path_step_retrieval(
if roadmap_first and roadmap_ctx is not None:
steps, roadmap_unfilled = _build_steps_roadmap_first(
cur,
tenant=tenant,
body=body,
goal_query=goal_query,
step_index=step_index,
max_steps=max_steps,
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
progression_graph_id=body.progression_graph_id,
include_llm_intent=body.include_llm_intent,
exercise_kind_any=body.exercise_kind_any,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
roadmap_ctx=roadmap_ctx,
)
planned_ids = [int(s["exercise_id"]) for s in steps if s.get("exercise_id") is not None]
if planned_ids:
anchor_id = planned_ids[-1]
anchor_variant_id = steps[-1].get("variant_id")
if body.include_ai_gap_fill and roadmap_unfilled:
major_by_index = (
{m.index: m for m in roadmap_ctx.roadmap.major_steps}
if roadmap_ctx.roadmap
else {}
)
roadmap_gap_specs = build_roadmap_unfilled_gap_specs(
unfilled_specs=roadmap_unfilled,
major_steps_by_index=major_by_index,
steps=steps,
brief=semantic_brief,
goal_query=goal_query,
goal_analysis=roadmap_ctx.goal_analysis if roadmap_ctx else None,
resolved_structured=roadmap_ctx.resolved_structured if roadmap_ctx else None,
)
for spec in roadmap_gap_specs:
roadmap_gap_offers.append(
build_gap_fill_offer(
spec=spec,
steps=steps,
goal_query=goal_query,
brief=semantic_brief,
proposal=None,
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
roadmap_ctx, spec, semantic_brief=semantic_brief
),
)
)
else:
for step_index in range(max_steps):
hits, _tp, _qis, _intent = _run_path_step_retrieval(
cur,
tenant=tenant,
goal_query=goal_query,
step_index=step_index,
max_steps=max_steps,
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
progression_graph_id=body.progression_graph_id,
include_llm_intent=body.include_llm_intent,
exercise_kind_any=body.exercise_kind_any,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
)
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
if not hit:
break
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
if not hit:
break
step = _hit_to_path_step(hit)
steps.append(step)
eid = int(step["exercise_id"])
used.add(eid)
planned_ids.append(eid)
anchor_id = eid
anchor_variant_id = step.get("variant_id")
step = _hit_to_path_step(hit)
steps.append(step)
eid = int(step["exercise_id"])
used.add(eid)
planned_ids.append(eid)
anchor_id = eid
anchor_variant_id = step.get("variant_id")
if len(steps) < 2:
raise HTTPException(
@ -398,15 +748,24 @@ def suggest_progression_path(
ai_proposals: List[Dict[str, Any]] = []
gap_fill_offers: List[Dict[str, Any]] = []
off_topic_steps: List[Dict[str, Any]] = []
stripped_off_topic: List[Dict[str, Any]] = []
llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False
reorder_applied = False
reorder_notes: List[str] = []
roadmap_qa_mode: Optional[str] = None
if body.include_path_qa:
gaps = detect_path_gaps(cur, steps, brief=semantic_brief)
if roadmap_first:
roadmap_qa_mode = "roadmap_first_lite"
gaps = detect_path_gaps(
cur,
steps,
brief=semantic_brief,
roadmap_first=roadmap_first,
)
unfilled_gaps: List[Dict[str, Any]] = []
if gaps:
if gaps and not roadmap_first:
bridge_fn = _make_bridge_search_fn(
cur,
tenant=tenant,
@ -427,6 +786,8 @@ def suggest_progression_path(
brief=semantic_brief,
bridge_search_fn=bridge_fn,
)
elif gaps and roadmap_first:
unfilled_gaps = list(gaps)
if body.include_llm_path_qa:
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
@ -438,7 +799,12 @@ def suggest_progression_path(
bridge_inserts=bridge_inserts,
)
if body.include_path_reorder and llm_qa_applied and llm_qa:
if (
body.include_path_reorder
and not roadmap_first
and llm_qa_applied
and llm_qa
):
q_score = llm_qa.get("quality_score")
try:
q_val = float(q_score) if q_score is not None else None
@ -448,6 +814,16 @@ def suggest_progression_path(
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps)
if stripped_off_topic:
off_topic_steps = []
gaps = detect_path_gaps(
cur,
steps,
brief=semantic_brief,
roadmap_first=roadmap_first,
)
llm_gap_specs = parse_llm_suggested_new_exercises(
llm_qa,
brief=semantic_brief,
@ -455,39 +831,68 @@ def suggest_progression_path(
)
if body.include_ai_gap_fill:
fresh_large_gaps = [g for g in gaps if g.get("is_large_gap")]
gap_specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=unfilled_gaps,
unfilled_gaps=fresh_large_gaps or unfilled_gaps,
off_topic_steps=off_topic_steps,
llm_specs=llm_gap_specs,
brief=semantic_brief,
goal_query=goal_query,
)
path_roadmap_snapshot = None
if roadmap_ctx:
path_roadmap_snapshot = build_progression_gap_snapshot(
goal_analysis=(
roadmap_ctx.goal_analysis.model_dump()
if roadmap_ctx.goal_analysis
else None
),
resolved_structured=(
roadmap_ctx.resolved_structured.model_dump()
if roadmap_ctx.resolved_structured
else None
),
semantic_brief=roadmap_ctx.semantic_brief or brief_to_summary_dict(semantic_brief),
)
steps, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
cur,
steps,
gap_specs,
goal_query=goal_query,
brief=semantic_brief,
include_ai_calls=True,
max_ai_proposals=3,
include_ai_calls=False,
max_ai_proposals=0,
auto_insert_proposals=False,
roadmap_snapshot=path_roadmap_snapshot,
)
if roadmap_gap_offers:
seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers}
for offer in roadmap_gap_offers:
if offer.get("offer_id") not in seen_offer_ids:
gap_fill_offers.append(offer)
path_qa = build_path_qa_summary(
gaps=gaps,
bridge_inserts=bridge_inserts,
ai_proposals=ai_proposals,
gap_fill_offers=gap_fill_offers,
off_topic_steps=off_topic_steps,
stripped_off_topic=stripped_off_topic,
llm_qa=llm_qa,
llm_applied=llm_qa_applied,
reorder_applied=reorder_applied,
reorder_notes=reorder_notes,
roadmap_qa_mode=roadmap_qa_mode,
)
target_profile_summary = path_target_profile.to_summary_dict(cur)
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
if roadmap_first:
retrieval_parts.append("roadmap_first")
if roadmap_qa_mode:
retrieval_parts.append(roadmap_qa_mode)
if body.include_path_qa:
retrieval_parts.append("path_qa")
if llm_qa_applied:
@ -498,6 +903,12 @@ def suggest_progression_path(
retrieval_parts.append("ai_gap_fill")
if gap_fill_offers:
retrieval_parts.append("gap_fill_offers")
if include_roadmap:
retrieval_parts.append("roadmap_preview")
if roadmap_edited:
retrieval_parts.append("roadmap_edited")
if roadmap_unfilled:
retrieval_parts.append("roadmap_unfilled")
return {
"goal_query": goal_query,
@ -511,6 +922,11 @@ def suggest_progression_path(
"progression_graph_id": body.progression_graph_id,
"path_qa": path_qa,
"gap_fill_offers": gap_fill_offers,
"progression_roadmap": progression_roadmap,
"roadmap_first": roadmap_first,
"roadmap_only": False,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": len(roadmap_unfilled),
"retrieval_phase": "+".join(retrieval_parts),
}

View File

@ -141,21 +141,45 @@ def measure_step_transition_gap(
}
def is_roadmap_planned_neighbor_pair(
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
) -> bool:
"""Aufeinanderfolgende Major Steps aus roadmap_first — kein Skill-Übergangs-Lücke."""
if step_a.get("roadmap_match_source") != "stage_spec":
return False
if step_b.get("roadmap_match_source") != "stage_spec":
return False
idx_a = step_a.get("roadmap_major_step_index")
idx_b = step_b.get("roadmap_major_step_index")
if idx_a is None or idx_b is None:
return False
try:
return int(idx_b) == int(idx_a) + 1
except (TypeError, ValueError):
return False
def detect_path_gaps(
cur,
steps: Sequence[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
roadmap_first: bool = False,
) -> List[Dict[str, Any]]:
if len(steps) < 2:
return []
gaps: List[Dict[str, Any]] = []
total_segments = len(steps) - 1
for i in range(total_segments):
step_a = steps[i]
step_b = steps[i + 1]
if roadmap_first and is_roadmap_planned_neighbor_pair(step_a, step_b):
continue
gap = measure_step_transition_gap(
cur,
steps[i],
steps[i + 1],
step_a,
step_b,
brief=brief,
segment_index=i,
total_segments=total_segments,
@ -462,6 +486,33 @@ def parse_llm_suggested_new_exercises(
return out
def strip_off_topic_steps_from_path(
steps: List[Dict[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
*,
min_remaining: int = 2,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Entfernt themenfremde Schritte aus dem Pfad (mindestens min_remaining bleiben)."""
if not off_topic_steps or len(steps) <= min_remaining:
return steps, []
by_index = {int(o["step_index"]): dict(o) for o in off_topic_steps if o.get("step_index") is not None}
indices = sorted(by_index.keys(), reverse=True)
if len(steps) - len(indices) < min_remaining:
return steps, []
out = list(steps)
removed: List[Dict[str, Any]] = []
for idx in indices:
if 0 <= idx < len(out):
entry = dict(by_index[idx])
entry["removed_title"] = out[idx].get("title")
entry["removed_exercise_id"] = out[idx].get("exercise_id")
removed.append(entry)
out.pop(idx)
return out, removed
def find_step_pair_index(
steps: Sequence[Mapping[str, Any]],
from_exercise_id: int,
@ -484,10 +535,12 @@ def build_path_qa_summary(
ai_proposals: Sequence[Mapping[str, Any]],
gap_fill_offers: Optional[Sequence[Mapping[str, Any]]] = None,
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
stripped_off_topic: Optional[Sequence[Mapping[str, Any]]] = None,
llm_qa: Optional[Mapping[str, Any]],
llm_applied: bool,
reorder_applied: bool = False,
reorder_notes: Optional[Sequence[str]] = None,
roadmap_qa_mode: Optional[str] = None,
) -> Dict[str, Any]:
offers = list(gap_fill_offers or [])
off_topic = list(off_topic_steps or [])
@ -502,9 +555,11 @@ def build_path_qa_summary(
"gap_fill_offers": offers,
"off_topic_count": len(off_topic),
"off_topic_steps": off_topic,
"stripped_off_topic_steps": list(stripped_off_topic or []),
"llm_qa_applied": llm_applied,
"reorder_applied": reorder_applied,
"reorder_notes": list(reorder_notes or []),
"roadmap_qa_mode": roadmap_qa_mode,
}
if llm_qa:
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
@ -533,6 +588,8 @@ __all__ = [
"build_path_qa_summary",
"detect_off_topic_steps",
"detect_path_gaps",
"is_roadmap_planned_neighbor_pair",
"strip_off_topic_steps_from_path",
"find_step_pair_index",
"insert_bridge_exercises",
"measure_step_transition_gap",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
"""
Modul-Registrierungen für Rechte & Kontingente.
Neues Feature: eigene Datei oder Eintrag hier importieren kein Eintrag in 079-Katalog-Migration.
"""
from rights_registrations import club_creation # noqa: F401
from rights_registrations import exercises # noqa: F401
from rights_registrations import planning # noqa: F401
from rights_registrations import platform # noqa: F401

View File

@ -0,0 +1,38 @@
from rights_registry import CapabilityRegistration, register_capability
register_capability(
CapabilityRegistration(
id="club.creation_request.create",
name="Vereinsgründung beantragen",
domain="club",
module="club_creation_requests",
min_account_state="verified_pending_club",
)
)
register_capability(
CapabilityRegistration(
id="club.creation_request.read_own",
name="Eigene Gründungsanträge",
domain="club",
module="club_creation_requests",
min_account_state="verified_pending_club",
)
)
register_capability(
CapabilityRegistration(
id="club.creation_request.withdraw",
name="Gründungsantrag zurückziehen",
domain="club",
module="club_creation_requests",
min_account_state="verified_pending_club",
)
)
register_capability(
CapabilityRegistration(
id="platform.club_creation.approve",
name="Vereinsgründung freigeben",
domain="platform",
module="club_creation_requests",
min_account_state="platform_admin",
)
)

View File

@ -0,0 +1,90 @@
"""Übungen-Modul: nur Rechte/Kontingente mit echter Endpoint-Verdrahtung."""
from rights_registry import CapabilityRegistration, FeatureRegistration, register_capability, register_feature
_CLUB_WRITE_ROLES = (
"club_admin",
"trainer",
"content_editor",
"division_lead",
)
register_feature(
FeatureRegistration(
id="ai_calls",
name="KI-Aufrufe",
module="exercises",
category="ai",
limit_type="count",
reset_period="monthly",
default_limit=0,
description="KI-Aufrufe pro Monat (Suggest, Regenerate)",
)
)
register_capability(
CapabilityRegistration(
id="exercises.ai.suggest",
name="KI-Vorschlag Übung",
domain="exercises",
module="exercises",
linked_feature_id="ai_calls",
default_club_grants=tuple((r, "exercises.ai.suggest") for r in _CLUB_WRITE_ROLES),
)
)
register_capability(
CapabilityRegistration(
id="exercises.ai.regenerate",
name="KI neu generieren",
domain="exercises",
module="exercises",
linked_feature_id="ai_calls",
default_club_grants=tuple((r, "exercises.ai.regenerate") for r in _CLUB_WRITE_ROLES),
)
)
register_capability(
CapabilityRegistration(
id="exercises.create",
name="Übung anlegen",
domain="exercises",
module="exercises",
linked_feature_id="exercises",
default_club_grants=tuple((r, "exercises.create") for r in _CLUB_WRITE_ROLES),
)
)
register_capability(
CapabilityRegistration(
id="exercises.media.upload",
name="Übungsmedien hochladen",
domain="exercises",
module="exercises",
linked_feature_id="exercise_media",
default_club_grants=(
("club_admin", "exercises.media.upload"),
("trainer", "exercises.media.upload"),
("content_editor", "exercises.media.upload"),
),
)
)
register_feature(
FeatureRegistration(
id="exercises",
name="Übungen (Bestand)",
module="exercises",
category="content",
limit_type="count",
reset_period="never",
default_limit=0,
)
)
register_feature(
FeatureRegistration(
id="exercise_media",
name="Übungsmedien",
module="exercises",
category="media",
limit_type="count",
reset_period="never",
default_limit=0,
)
)

View File

@ -0,0 +1,24 @@
from rights_registry import CapabilityRegistration, FeatureRegistration, register_capability, register_feature
_PLANNING_ROLES = ("club_admin", "trainer", "division_lead")
register_capability(
CapabilityRegistration(
id="planning.ai.suggest",
name="Planungs-KI Suggest",
domain="planning",
module="planning_exercise_suggest",
linked_feature_id="ai_calls",
default_club_grants=tuple((r, "planning.ai.suggest") for r in _PLANNING_ROLES),
)
)
register_capability(
CapabilityRegistration(
id="planning.ai.progression_path",
name="Planungs-KI Progressionspfad",
domain="planning",
module="planning_exercise_suggest",
linked_feature_id="ai_calls",
default_club_grants=tuple((r, "planning.ai.progression_path") for r in _PLANNING_ROLES),
)
)

View File

@ -0,0 +1,21 @@
"""Plattform-Modul: Admin-Zugang und Quota-Bypass (083)."""
from rights_registry import CapabilityRegistration, register_capability
register_capability(
CapabilityRegistration(
id="platform.admin.access",
name="Plattform-Admin-Bereich",
domain="platform",
module="platform",
min_account_state="platform_admin",
)
)
register_capability(
CapabilityRegistration(
id="platform.club_quota.bypass",
name="Vereins-Kontingent-Bypass",
domain="quota_bypass",
module="platform",
min_account_state="platform_admin",
)
)

159
backend/rights_registry.py Normal file
View File

@ -0,0 +1,159 @@
"""
Registry-first: Module melden Rechte (capabilities) und Kontingente (features) bei Implementierung an.
Kein vollständiger Vorab-Katalog nur was ein Modul wirklich liefert, erscheint konfigurierbar
in Admin Rollen & Rechte (Filter: module IS NOT NULL).
Spez: docs/working/RIGHTS_AND_FEATURES_REGISTRY.md
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Sequence, Tuple
from db import get_db, get_cursor
GrantPair = Tuple[str, str] # (role_code, capability_id)
@dataclass(frozen=True)
class CapabilityRegistration:
id: str
name: str
domain: str
module: str
min_account_state: str = "active_member"
linked_feature_id: Optional[str] = None
description: Optional[str] = None
default_club_grants: Sequence[GrantPair] = field(default_factory=tuple)
@dataclass(frozen=True)
class FeatureRegistration:
id: str
name: str
module: str
category: str = "general"
limit_type: str = "count"
reset_period: str = "never"
default_limit: Optional[int] = None
description: Optional[str] = None
enforcement_subject: str = "club"
_CAPABILITY_REGISTRY: Dict[str, CapabilityRegistration] = {}
_FEATURE_REGISTRY: Dict[str, FeatureRegistration] = {}
def register_capability(defn: CapabilityRegistration) -> None:
"""Modul deklariert ein Recht — wird beim Startup in die DB synchronisiert."""
if not defn.module or not defn.id:
raise ValueError("CapabilityRegistration: module und id sind Pflicht")
_CAPABILITY_REGISTRY[defn.id] = defn
def register_feature(defn: FeatureRegistration) -> None:
"""Modul deklariert ein Vereins-Kontingent."""
if not defn.module or not defn.id:
raise ValueError("FeatureRegistration: module und id sind Pflicht")
_FEATURE_REGISTRY[defn.id] = defn
def registered_capabilities() -> Dict[str, CapabilityRegistration]:
return dict(_CAPABILITY_REGISTRY)
def registered_features() -> Dict[str, FeatureRegistration]:
return dict(_FEATURE_REGISTRY)
def _upsert_capability(cur, defn: CapabilityRegistration) -> None:
cur.execute(
"""
INSERT INTO capabilities (
id, name, description, domain, min_account_state,
linked_feature_id, active, module, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, true, %s, NOW())
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
domain = EXCLUDED.domain,
min_account_state = EXCLUDED.min_account_state,
linked_feature_id = EXCLUDED.linked_feature_id,
active = true,
module = EXCLUDED.module,
updated_at = NOW()
""",
(
defn.id,
defn.name,
defn.description,
defn.domain,
defn.min_account_state,
defn.linked_feature_id,
defn.module,
),
)
for role_code, cap_id in defn.default_club_grants:
cur.execute(
"""
INSERT INTO club_role_capability_grants (role_code, capability_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(role_code, cap_id),
)
def _upsert_feature(cur, defn: FeatureRegistration) -> None:
cur.execute(
"""
INSERT INTO features (
id, app, name, description, category, limit_type,
reset_period, default_limit, enforcement_subject, active, module
)
VALUES (%s, 'shinkan', %s, %s, %s, %s, %s, %s, %s, true, %s)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
limit_type = EXCLUDED.limit_type,
reset_period = EXCLUDED.reset_period,
default_limit = EXCLUDED.default_limit,
enforcement_subject = EXCLUDED.enforcement_subject,
active = true,
module = EXCLUDED.module
""",
(
defn.id,
defn.name,
defn.description,
defn.category,
defn.limit_type,
defn.reset_period,
defn.default_limit,
defn.enforcement_subject,
defn.module,
),
)
def sync_rights_registry_to_db() -> Dict[str, int]:
"""
Startup: registrierte Module DB. Admin-Matrix zeigt nur Einträge mit module.
"""
import rights_registrations # noqa: F401 — lädt alle Modul-Registrierungen
with get_db() as conn:
cur = get_cursor(conn)
for defn in _CAPABILITY_REGISTRY.values():
_upsert_capability(cur, defn)
for defn in _FEATURE_REGISTRY.values():
_upsert_feature(cur, defn)
conn.commit()
return {
"capabilities": len(_CAPABILITY_REGISTRY),
"features": len(_FEATURE_REGISTRY),
}

View File

@ -0,0 +1,603 @@
"""
Superadmin: Rollen & Rechte Capability-Grants, Kontingent-Bypass, Vereins-Kontingente.
Ein Router für das Rechtesystem (M6). Kein paralleles Exemption-Schema.
"""
import os
from typing import Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from auth import require_auth
from club_quota_bypass import (
QUOTA_BYPASS_ALL,
ensure_quota_bypass_capability,
list_quota_bypass_grants,
quota_bypass_capability_id_for_feature,
)
from capabilities import capability_enforcement_enabled
from capability_enforcement_audit import (
enforcement_status_for_capability,
feature_consume_status,
)
from club_features import club_feature_enforcement_enabled
from club_tenancy import is_superadmin
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin/rights", tags=["admin_rights"])
PORTAL_ROLES = ("user", "trainer", "admin", "superadmin")
CLUB_ROLES = ("club_admin", "trainer", "division_lead", "content_editor")
def _require_superadmin(session: dict) -> None:
if not is_superadmin(session.get("role")):
raise HTTPException(status_code=403, detail="Nur Super-Administratoren")
def _resolve_quota_bypass_capability_id(cur, feature_id: Optional[str]) -> str:
fid = (feature_id or "").strip() or None
if not fid:
return QUOTA_BYPASS_ALL
cur.execute("SELECT 1 FROM features WHERE id = %s", (fid,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Unbekanntes Feature")
return ensure_quota_bypass_capability(cur, fid)
class PlanLimitItem(BaseModel):
feature_id: str
limit_value: Optional[int] = Field(
None,
description="NULL = unbegrenzt; 0 = deaktiviert (boolean/count)",
)
class PlanLimitsBody(BaseModel):
limits: List[PlanLimitItem]
class ClubSubscriptionBody(BaseModel):
plan_id: str
status: str = Field(default="active", pattern="^(active|trial|past_due|cancelled)$")
class PortalCapabilityGrantBody(BaseModel):
portal_role: str = Field(..., min_length=1, max_length=50)
capability_id: str = Field(..., min_length=1)
class ClubRoleCapabilityGrantBody(BaseModel):
role_code: str = Field(..., min_length=1, max_length=50)
capability_id: str = Field(..., min_length=1)
class QuotaBypassPortalBody(BaseModel):
portal_role: str = Field(..., min_length=1, max_length=50)
feature_id: Optional[str] = Field(
None,
description="Feature-ID oder leer = alle Vereins-Features (platform.club_quota.bypass)",
)
class QuotaBypassProfileBody(BaseModel):
feature_id: Optional[str] = Field(None, description="Feature-ID oder leer = alle Features")
reason: Optional[str] = Field(None, max_length=500)
# ── Enforcement-Diagnose (Superadmin) ────────────────────────────────────────
@router.get("/enforcement-status")
def get_enforcement_status(session: dict = Depends(require_auth)):
"""Prüft ob Hard-Block-Env im laufenden Container/Prozess ankommt."""
_require_superadmin(session)
raw = os.getenv("CLUB_FEATURE_ENFORCE", "0")
return {
"club_feature_enforce_active": club_feature_enforcement_enabled(),
"club_feature_enforce_raw": raw,
"capability_enforce_active": capability_enforcement_enabled(),
"capability_enforce_raw": os.getenv("CAPABILITY_ENFORCE", "0"),
"hints": [
"Nach .env-Änderung: docker compose up -d backend (Container neu erstellen).",
"Superadmin hat Quota-Bypass — KI-Limit-Test als Trainer, nicht als Superadmin.",
"Ohne aktiven Verein (X-Active-Club-Id) blockiert Enforce ebenfalls.",
],
}
# ── Capability-Matrix (Rollen → Fähigkeiten) ─────────────────────────────────
@router.get("/capability-matrix")
def get_capability_matrix(session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name, domain, min_account_state, linked_feature_id, module
FROM capabilities
WHERE active = true AND module IS NOT NULL
ORDER BY module, domain, id
"""
)
capabilities = []
for row in cur.fetchall():
cap = r2d(row)
cap["enforcement"] = enforcement_status_for_capability(cap.get("id"))
if cap.get("linked_feature_id"):
cap["feature_consume"] = feature_consume_status(cap["linked_feature_id"])
capabilities.append(cap)
cur.execute(
"""
SELECT portal_role, capability_id
FROM portal_role_capability_grants
ORDER BY portal_role, capability_id
"""
)
portal_grants = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT role_code, capability_id
FROM club_role_capability_grants
ORDER BY role_code, capability_id
"""
)
club_role_grants = [r2d(r) for r in cur.fetchall()]
return {
"portal_roles": list(PORTAL_ROLES),
"club_roles": list(CLUB_ROLES),
"capabilities": capabilities,
"portal_grants": portal_grants,
"club_role_grants": club_role_grants,
"registry_only": True,
"hint": (
"Nur vom Modul registrierte Rechte (capabilities.module). "
"Legacy-Katalog-Seed ohne module erscheint nicht."
),
}
@router.post("/capability-grants/portal-roles", status_code=201)
def add_portal_capability_grant(body: PortalCapabilityGrantBody, session: dict = Depends(require_auth)):
_require_superadmin(session)
role = body.portal_role.strip().lower()
cap_id = body.capability_id.strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT domain FROM capabilities WHERE id = %s AND active = true", (cap_id,))
cap = cur.fetchone()
if not cap:
raise HTTPException(status_code=400, detail="Unbekannte Capability")
domain = (cap.get("domain") or "").lower()
if domain not in ("platform", "quota_bypass") and not cap_id.startswith("platform."):
raise HTTPException(
status_code=400,
detail="Portal-Grants nur für domain=platform oder quota_bypass",
)
cur.execute(
"""
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
RETURNING portal_role, capability_id
""",
(role, cap_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=409, detail="Grant existiert bereits")
conn.commit()
return r2d(row)
@router.delete("/capability-grants/portal-roles")
def delete_portal_capability_grant(
portal_role: str = Query(...),
capability_id: str = Query(...),
session: dict = Depends(require_auth),
):
_require_superadmin(session)
role = portal_role.strip().lower()
cap_id = capability_id.strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
DELETE FROM portal_role_capability_grants
WHERE portal_role = %s AND capability_id = %s
RETURNING portal_role, capability_id
""",
(role, cap_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
conn.commit()
return {"ok": True}
@router.post("/capability-grants/club-roles", status_code=201)
def add_club_role_capability_grant(
body: ClubRoleCapabilityGrantBody,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
role = body.role_code.strip().lower()
cap_id = body.capability_id.strip()
if role not in CLUB_ROLES:
raise HTTPException(status_code=400, detail="Unbekannte Vereinsrolle")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT domain FROM capabilities
WHERE id = %s AND active = true AND domain NOT IN ('platform', 'quota_bypass')
""",
(cap_id,),
)
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Capability nicht für Vereinsrollen")
cur.execute(
"""
INSERT INTO club_role_capability_grants (role_code, capability_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
RETURNING role_code, capability_id
""",
(role, cap_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=409, detail="Grant existiert bereits")
conn.commit()
return r2d(row)
@router.delete("/capability-grants/club-roles/by-capability")
def clear_club_capability_grants(
capability_id: str = Query(...),
session: dict = Depends(require_auth),
):
"""Alle Rollen-Grants einer Capability entfernen → wieder offen für alle Mitglieder."""
_require_superadmin(session)
cap_id = capability_id.strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
DELETE FROM club_role_capability_grants
WHERE capability_id = %s
""",
(cap_id,),
)
conn.commit()
return {"ok": True, "capability_id": cap_id}
@router.delete("/capability-grants/club-roles")
def delete_club_role_capability_grant(
role_code: str = Query(...),
capability_id: str = Query(...),
session: dict = Depends(require_auth),
):
_require_superadmin(session)
role = role_code.strip().lower()
cap_id = capability_id.strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
DELETE FROM club_role_capability_grants
WHERE role_code = %s AND capability_id = %s
RETURNING role_code, capability_id
""",
(role, cap_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
conn.commit()
return {"ok": True}
# ── Kontingent-Bypass (Capability-Grants) ───────────────────────────────────
@router.get("/quota-bypass")
def list_quota_bypass(session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
return list_quota_bypass_grants(cur)
@router.post("/quota-bypass/portal-roles", status_code=201)
def add_quota_bypass_portal_grant(body: QuotaBypassPortalBody, session: dict = Depends(require_auth)):
_require_superadmin(session)
role = body.portal_role.strip().lower()
with get_db() as conn:
cur = get_cursor(conn)
cap_id = _resolve_quota_bypass_capability_id(cur, body.feature_id)
cur.execute(
"""
SELECT 1 FROM portal_role_capability_grants
WHERE portal_role = %s AND capability_id = %s
LIMIT 1
""",
(role, cap_id),
)
if cur.fetchone():
raise HTTPException(status_code=409, detail="Grant existiert bereits")
cur.execute(
"""
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
VALUES (%s, %s)
RETURNING portal_role, capability_id
""",
(role, cap_id),
)
row = cur.fetchone()
conn.commit()
out = r2d(row)
out["capability_id"] = cap_id
out["feature_id"] = (body.feature_id or "").strip() or None
return out
@router.delete("/quota-bypass/portal-roles")
def delete_quota_bypass_portal_grant(
portal_role: str = Query(...),
capability_id: Optional[str] = Query(None),
feature_id: Optional[str] = Query(None),
session: dict = Depends(require_auth),
):
_require_superadmin(session)
role = portal_role.strip().lower()
cap_id = capability_id
if not cap_id:
cap_id = (
QUOTA_BYPASS_ALL
if not (feature_id or "").strip()
else quota_bypass_capability_id_for_feature(feature_id.strip())
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
DELETE FROM portal_role_capability_grants
WHERE portal_role = %s AND capability_id = %s
RETURNING portal_role, capability_id
""",
(role, cap_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
conn.commit()
return {"ok": True}
@router.post("/quota-bypass/profiles/{profile_id}", status_code=201)
def add_quota_bypass_profile_grant(
profile_id: int,
body: QuotaBypassProfileBody,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
admin_pid = int(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (profile_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
cap_id = _resolve_quota_bypass_capability_id(cur, body.feature_id)
cur.execute(
"""
SELECT 1 FROM profile_capability_grants
WHERE profile_id = %s AND capability_id = %s
LIMIT 1
""",
(profile_id, cap_id),
)
if cur.fetchone():
raise HTTPException(status_code=409, detail="Grant existiert bereits")
cur.execute(
"""
INSERT INTO profile_capability_grants (
profile_id, capability_id, reason, granted_by_profile_id
)
VALUES (%s, %s, %s, %s)
RETURNING profile_id, capability_id, reason, granted_by_profile_id, created_at
""",
(profile_id, cap_id, (body.reason or "").strip() or None, admin_pid),
)
row = cur.fetchone()
conn.commit()
return r2d(row)
@router.delete("/quota-bypass/profiles")
def delete_quota_bypass_profile_grant(
profile_id: int = Query(...),
capability_id: Optional[str] = Query(None),
feature_id: Optional[str] = Query(None),
session: dict = Depends(require_auth),
):
_require_superadmin(session)
cap_id = capability_id
if not cap_id:
cap_id = (
QUOTA_BYPASS_ALL
if not (feature_id or "").strip()
else quota_bypass_capability_id_for_feature(feature_id.strip())
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
DELETE FROM profile_capability_grants
WHERE profile_id = %s AND capability_id = %s
RETURNING profile_id, capability_id
""",
(profile_id, cap_id),
)
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Grant nicht gefunden")
conn.commit()
return {"ok": True}
# ── Vereins-Kontingente (Pläne & Zuordnung) ─────────────────────────────────
@router.get("/club-plans/matrix")
def get_club_plans_matrix(session: dict = Depends(require_auth)):
"""Aktive Vereinspläne, club-scoped Features und Limit-Matrix."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, name, description, sort_order, active
FROM club_plans
WHERE active = true
ORDER BY sort_order, id
"""
)
plans = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, name, description, category, limit_type, reset_period, default_limit, module
FROM features
WHERE app = 'shinkan' AND active = true AND enforcement_subject = 'club'
AND module IS NOT NULL
ORDER BY module, category, id
"""
)
features = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT plan_id, feature_id, limit_value
FROM club_plan_limits
WHERE plan_id IN (SELECT id FROM club_plans WHERE active = true)
"""
)
limits: Dict[str, Dict[str, Optional[int]]] = {}
for row in cur.fetchall():
pid = row["plan_id"]
fid = row["feature_id"]
limits.setdefault(pid, {})[fid] = row.get("limit_value")
return {"plans": plans, "features": features, "limits": limits}
@router.put("/club-plans/{plan_id}/limits")
def update_club_plan_limits(
plan_id: str,
body: PlanLimitsBody,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
plan_id = plan_id.strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT 1 FROM club_plans WHERE id = %s AND active = true", (plan_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Plan nicht gefunden")
for item in body.limits:
fid = item.feature_id.strip()
cur.execute(
"SELECT 1 FROM features WHERE id = %s AND app = 'shinkan'",
(fid,),
)
if not cur.fetchone():
raise HTTPException(status_code=400, detail=f"Unbekanntes Feature: {fid}")
cur.execute(
"""
INSERT INTO club_plan_limits (plan_id, feature_id, limit_value)
VALUES (%s, %s, %s)
ON CONFLICT (plan_id, feature_id)
DO UPDATE SET limit_value = EXCLUDED.limit_value
""",
(plan_id, fid, item.limit_value),
)
conn.commit()
return {"ok": True, "plan_id": plan_id, "updated": len(body.limits)}
@router.get("/club-subscriptions")
def list_club_subscriptions(session: dict = Depends(require_auth)):
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT c.id AS club_id, c.name AS club_name,
cs.plan_id, cs.status, cs.started_at, cs.ends_at
FROM clubs c
LEFT JOIN club_subscriptions cs ON cs.club_id = c.id
ORDER BY lower(c.name), c.id
"""
)
rows = []
for r in cur.fetchall():
d = r2d(r)
if not d.get("plan_id"):
d["plan_id"] = "free"
d["status"] = "active"
rows.append(d)
return rows
@router.put("/clubs/{club_id}/subscription")
def update_club_subscription(
club_id: int,
body: ClubSubscriptionBody,
session: dict = Depends(require_auth),
):
_require_superadmin(session)
plan_id = body.plan_id.strip()
status = body.status.strip().lower()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Verein nicht gefunden")
cur.execute("SELECT 1 FROM club_plans WHERE id = %s AND active = true", (plan_id,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Unbekannter Plan")
cur.execute(
"""
INSERT INTO club_subscriptions (club_id, plan_id, status)
VALUES (%s, %s, %s)
ON CONFLICT (club_id)
DO UPDATE SET plan_id = EXCLUDED.plan_id, status = EXCLUDED.status, updated_at = NOW()
RETURNING club_id, plan_id, status
""",
(club_id, plan_id, status),
)
row = cur.fetchone()
conn.commit()
return r2d(row)

View File

@ -0,0 +1,398 @@
"""
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 _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]:
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 _normalize_creation_request_row(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 [_normalize_creation_request_row(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}

View File

@ -336,6 +336,16 @@ def delete_club(club_id: int, tenant: TenantContext = Depends(get_tenant_context
if not cur.fetchone():
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)
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
conn.commit()

View File

@ -37,6 +37,14 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar
from media_legal_hold import assert_not_under_legal_hold
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from account_lifecycle import assert_min_account_state
from capabilities import probe_capability
from club_features import (
consume_club_feature_with_usage,
merge_feature_usage_into_response,
probe_club_feature_access,
resolve_club_id_for_probe,
)
from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS,
@ -377,6 +385,10 @@ class ExerciseAiSuggestBody(BaseModel):
include_summary: bool = True
include_skills: bool = True
include_instructions: bool = False
planning_context: Optional[dict] = Field(
default=None,
description="Optionaler Planungskontext (Einheit, Pfad, Roadmap-Stufe) für KI-Neuanlage",
)
@model_validator(mode="after")
def check_include_any(self):
@ -395,6 +407,7 @@ class ExerciseAiSuggestBody(BaseModel):
trainer_notes=self.trainer_notes,
focus_area_hint=self.focus_area_hint,
focus_areas_context=self.focus_areas_context,
planning_context=self.planning_context,
)
@ -2317,7 +2330,24 @@ def exercise_ai_suggest_endpoint(
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
OPENROUTER_API_KEY erforderlich.
"""
_ = tenant.profile_id
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/ai/suggest")
club_id = resolve_club_id_for_probe(tenant)
probe_capability(
tenant,
"exercises.ai.suggest",
action="suggest",
club_id=club_id,
endpoint="POST /exercises/ai/suggest",
)
probe_club_feature_access(
feature_id="ai_calls",
action="suggest",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
endpoint="POST /exercises/ai/suggest",
tenant=tenant,
)
with get_db() as conn:
cur = get_cursor(conn)
payload = run_exercise_form_ai_suggestion(
@ -2327,6 +2357,17 @@ def exercise_ai_suggest_endpoint(
want_skills=body.include_skills,
want_instructions=body.include_instructions,
)
usage = consume_club_feature_with_usage(
feature_id="ai_calls",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
action="suggest",
cur=cur,
tenant=tenant,
conn=conn,
)
payload = merge_feature_usage_into_response(payload, usage)
return payload
@ -2337,6 +2378,24 @@ def exercise_ai_regenerate_endpoint(
tenant: TenantContext = Depends(get_tenant_context),
):
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/ai/regenerate")
club_id = resolve_club_id_for_probe(tenant)
probe_capability(
tenant,
"exercises.ai.regenerate",
action="regenerate",
club_id=club_id,
endpoint="POST /exercises/{id}/ai/regenerate",
)
probe_club_feature_access(
feature_id="ai_calls",
action="regenerate",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
endpoint="POST /exercises/{id}/ai/regenerate",
tenant=tenant,
)
want_summary = "summary" in body.regenerate
want_skills = "skills" in body.regenerate
want_instructions = "instructions" in body.regenerate
@ -2368,6 +2427,17 @@ def exercise_ai_regenerate_endpoint(
want_skills=want_skills,
want_instructions=want_instructions,
)
usage = consume_club_feature_with_usage(
feature_id="ai_calls",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
action="regenerate",
cur=cur,
tenant=tenant,
conn=conn,
)
payload = merge_feature_usage_into_response(payload, usage)
return payload
@ -2421,6 +2491,25 @@ def create_exercise(
if body.visibility == "club" and club_id is None:
club_id = tenant.effective_club_id
if club_id is not None:
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises")
probe_capability(
tenant,
"exercises.create",
action="create",
club_id=int(club_id),
endpoint="POST /exercises",
)
probe_club_feature_access(
feature_id="exercises",
action="create",
club_id=int(club_id),
profile_id=profile_id,
portal_role=tenant.global_role,
endpoint="POST /exercises",
tenant=tenant,
)
# §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich
create_ids: set[int] = set()
for fld in sorted(RICH_HTML_EXERCISE_FIELDS):
@ -3214,6 +3303,34 @@ async def upload_exercise_media(
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
if has_file:
cur.execute(
"SELECT club_id FROM exercises WHERE id = %s",
(exercise_id,),
)
ex_club = cur.fetchone()
media_club_id = ex_club.get("club_id") if ex_club else None
if media_club_id is not None:
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/media")
probe_capability(
tenant,
"exercises.media.upload",
action="upload",
club_id=int(media_club_id),
endpoint="POST /exercises/{id}/media",
conn=conn,
)
probe_club_feature_access(
feature_id="exercise_media",
action="upload",
club_id=int(media_club_id),
profile_id=profile_id,
portal_role=tenant.global_role,
endpoint="POST /exercises/{id}/media",
tenant=tenant,
conn=conn,
)
if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
raise HTTPException(
status_code=400,

View File

@ -0,0 +1,27 @@
"""
GET /api/me/entitlements effektive Capabilities + Feature-Kontingente (M4).
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from db import get_db, get_cursor
from entitlements import build_me_entitlements
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["entitlements"])
@router.get("/me/entitlements")
def get_me_entitlements(
tenant: TenantContext = Depends(get_tenant_context),
club_id: Optional[int] = Query(default=None, ge=1, description="Verein (Default: effective_club_id)"),
):
"""
Effektive Rechte für Frontend: Account-Status, Capabilities, Feature-Limits.
Spez: CAPABILITY_CATALOG.v1.md §7.1
"""
with get_db() as conn:
cur = get_cursor(conn)
return build_me_entitlements(cur, tenant, club_id=club_id)

View File

@ -7,6 +7,14 @@ from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
from account_lifecycle import assert_min_account_state
from capabilities import probe_capability
from club_features import (
consume_club_feature_with_usage,
merge_feature_usage_into_response,
probe_club_feature_access,
resolve_club_id_for_probe,
)
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
@ -16,9 +24,42 @@ def post_planning_exercise_suggest(
body: PlanningExerciseSuggestRequest,
tenant: TenantContext = Depends(get_tenant_context),
):
uses_ai = body.include_llm_intent or body.include_llm_rank
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
if uses_ai:
assert_min_account_state(tenant, "active_member", endpoint="POST /planning/exercise-suggest")
probe_capability(
tenant,
"planning.ai.suggest",
action="planning_suggest",
club_id=club_id,
endpoint="POST /planning/exercise-suggest",
)
probe_club_feature_access(
feature_id="ai_calls",
action="planning_suggest",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
endpoint="POST /planning/exercise-suggest",
tenant=tenant,
)
with get_db() as conn:
cur = get_cursor(conn)
return suggest_planning_exercises(cur, tenant=tenant, body=body)
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
if uses_ai:
usage = consume_club_feature_with_usage(
feature_id="ai_calls",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
action="planning_suggest",
cur=cur,
tenant=tenant,
conn=conn,
)
result = merge_feature_usage_into_response(result, usage)
return result
@router.post("/progression-path-suggest")
@ -26,6 +67,48 @@ def post_progression_path_suggest(
body: ProgressionPathSuggestRequest,
tenant: TenantContext = Depends(get_tenant_context),
):
uses_ai = (
body.include_llm_intent
or body.include_llm_path_qa
or body.include_ai_gap_fill
or body.include_llm_roadmap
or body.include_llm_start_target
or (body.start_target_only and body.include_llm_start_target)
)
club_id = resolve_club_id_for_probe(tenant) if uses_ai else None
if uses_ai:
assert_min_account_state(
tenant, "active_member", endpoint="POST /planning/progression-path-suggest"
)
probe_capability(
tenant,
"planning.ai.progression_path",
action="progression_path_suggest",
club_id=club_id,
endpoint="POST /planning/progression-path-suggest",
)
probe_club_feature_access(
feature_id="ai_calls",
action="progression_path_suggest",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
endpoint="POST /planning/progression-path-suggest",
tenant=tenant,
)
with get_db() as conn:
cur = get_cursor(conn)
return suggest_progression_path(cur, tenant=tenant, body=body)
result = suggest_progression_path(cur, tenant=tenant, body=body)
if uses_ai:
usage = consume_club_feature_with_usage(
feature_id="ai_calls",
club_id=club_id,
profile_id=tenant.profile_id,
portal_role=tenant.global_role,
action="progression_path_suggest",
cur=cur,
tenant=tenant,
conn=conn,
)
result = merge_feature_usage_into_response(result, usage)
return result

View File

@ -22,6 +22,7 @@ from club_tenancy import (
is_superadmin,
memberships_with_roles,
)
from capabilities import club_roles_in_club
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
from models import ProfileCreate, ProfileUpdate
@ -118,6 +119,11 @@ def get_current_profile(
invalid_header_policy="ignore",
)
data["effective_club_id"] = tenant.effective_club_id
data["account_state"] = tenant.account_state
if tenant.effective_club_id is not None:
data["club_roles"] = club_roles_in_club(tenant, tenant.effective_club_id)
else:
data["club_roles"] = []
return data

View File

@ -27,6 +27,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
"admin_rights.py", # Superadmin Rollen/Rechte (Capabilities, Kontingent-Bypass, Pläne); require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py",
"skills.py",
"maturity_models.py",

View File

@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
from fastapi import Depends, Header, HTTPException
from auth import require_auth, require_auth_flexible
from account_lifecycle import resolve_account_state
from club_tenancy import is_platform_admin, memberships_with_roles
from db import get_db, get_cursor
@ -142,6 +143,8 @@ class TenantContext:
effective_club_id: Optional[int]
club_ids: frozenset[int]
memberships: List[Dict[str, Any]]
email_verified: bool = True
account_state: str = "active_member"
def resolve_tenant_context(
@ -153,6 +156,7 @@ def resolve_tenant_context(
memberships: Optional[List[Dict[str, Any]]] = None,
stored_active_club_id: Optional[int] = None,
invalid_header_policy: str = "reject",
email_verified: Optional[bool] = None,
) -> TenantContext:
"""
Mitgliedschaften: wenn nicht übergeben, lädt ``active_only=True`` aus der DB.
@ -176,6 +180,21 @@ def resolve_tenant_context(
club_ids = frozenset(int(r["id"]) for r in membership_rows if r.get("id") is not None)
if email_verified is None:
cur.execute(
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
(profile_id,),
)
prof_row = cur.fetchone()
email_verified = bool(prof_row.get("email_verified")) if prof_row else False
else:
email_verified = bool(email_verified)
account_state = resolve_account_state(
email_verified=email_verified,
global_role=role_lc,
has_active_membership=len(club_ids) > 0,
)
if is_platform_admin(role_lc):
if header_cid is not None:
if not _club_exists(cur, header_cid):
@ -194,6 +213,8 @@ def resolve_tenant_context(
effective_club_id=effective,
club_ids=club_ids,
memberships=membership_rows,
email_verified=email_verified,
account_state=account_state,
)
chosen_header = header_cid
@ -222,6 +243,8 @@ def resolve_tenant_context(
effective_club_id=effective,
club_ids=club_ids,
memberships=membership_rows,
email_verified=email_verified,
account_state=account_state,
)

View File

@ -92,6 +92,7 @@ def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch):
header_raw=None,
memberships=[{"id": 10}],
stored_active_club_id=99,
email_verified=True,
)
assert ctx.effective_club_id == 99
@ -110,6 +111,7 @@ def test_resolve_platform_admin_header_overrides_stored(monkeypatch):
header_raw="5",
memberships=[{"id": 10}],
stored_active_club_id=99,
email_verified=True,
)
assert ctx.effective_club_id == 5
@ -124,6 +126,7 @@ def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch):
header_raw=None,
memberships=[{"id": 1}],
stored_active_club_id=123,
email_verified=True,
)
assert ctx.effective_club_id is None
@ -142,6 +145,7 @@ def test_resolve_trainer_club_ids_excludes_inactive_memberships():
],
stored_active_club_id=None,
invalid_header_policy="ignore",
email_verified=True,
)
assert ctx.club_ids == frozenset({20})
assert ctx.effective_club_id == 20
@ -157,6 +161,7 @@ def test_resolve_all_memberships_inactive_no_effective_club():
memberships=[{"id": 10, "membership_status": "inactive"}],
stored_active_club_id=10,
invalid_header_policy="ignore",
email_verified=True,
)
assert ctx.club_ids == frozenset()
assert ctx.effective_club_id is None

View File

@ -0,0 +1,86 @@
"""Unit-Tests für Account-Lifecycle und Capability-Helfer (ohne DB)."""
import pytest
from fastapi import HTTPException
from account_lifecycle import (
account_state_satisfies,
assert_min_account_state,
resolve_account_state,
)
from capabilities import club_roles_in_club
from tenant_context import TenantContext
def test_resolve_account_state_platform_admin():
assert (
resolve_account_state(email_verified=False, global_role="superadmin", has_active_membership=False)
== "platform_admin"
)
def test_resolve_account_state_unverified():
assert (
resolve_account_state(email_verified=False, global_role="trainer", has_active_membership=True)
== "unverified"
)
def test_resolve_account_state_pending_club():
assert (
resolve_account_state(email_verified=True, global_role="user", has_active_membership=False)
== "verified_pending_club"
)
def test_resolve_account_state_active_member():
assert (
resolve_account_state(email_verified=True, global_role="trainer", has_active_membership=True)
== "active_member"
)
def test_account_state_satisfies():
assert account_state_satisfies("active_member", "active_member")
assert account_state_satisfies("active_member", "verified_pending_club")
assert not account_state_satisfies("verified_pending_club", "active_member")
assert account_state_satisfies("platform_admin", "active_member")
def test_assert_min_account_state_blocks(monkeypatch):
monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "1")
tenant = TenantContext(
profile_id=1,
global_role="user",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
account_state="verified_pending_club",
)
with pytest.raises(HTTPException) as exc:
assert_min_account_state(tenant, "active_member")
assert exc.value.status_code == 403
def test_assert_min_account_state_off(monkeypatch):
monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "0")
tenant = TenantContext(
profile_id=1,
global_role="user",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
account_state="verified_pending_club",
)
assert_min_account_state(tenant, "active_member")
def test_club_roles_in_club():
tenant = TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[{"id": 5, "roles": ["trainer", "club_admin"]}],
)
assert club_roles_in_club(tenant, 5) == ["trainer", "club_admin"]
assert club_roles_in_club(tenant, 99) == []

View File

@ -0,0 +1,68 @@
"""Tests für account_onboarding_gate."""
import pytest
from account_onboarding_gate import (
check_api_onboarding_gate,
is_public_api_path,
normalize_api_path,
)
def test_public_directory_is_public():
assert is_public_api_path("/api/clubs/public-directory")
def test_exercises_blocked_for_pending():
allowed, reason = check_api_onboarding_gate(
path="/api/exercises",
method="GET",
profile_id=1,
account_state="verified_pending_club",
)
assert not allowed
assert reason == "account_state_verified_pending_club"
def test_join_request_allowed_for_pending():
allowed, _ = check_api_onboarding_gate(
path="/api/me/club-join-requests",
method="POST",
profile_id=1,
account_state="verified_pending_club",
)
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",
method="GET",
profile_id=1,
account_state="active_member",
)
assert allowed
assert reason is None
def test_profile_self_update_allowed_unverified():
allowed, _ = check_api_onboarding_gate(
path="/api/profiles/42",
method="PUT",
profile_id=42,
account_state="unverified",
)
assert allowed
def test_normalize_trailing_slash():
assert normalize_api_path("/api/exercises/") == "/api/exercises"

View File

@ -0,0 +1,21 @@
"""M6: Admin-Rollen/Rechte-API — Zugriffskontrolle."""
import pytest
from fastapi import HTTPException
from routers.admin_rights import get_capability_matrix, _require_superadmin
def test_require_superadmin_denies_admin():
with pytest.raises(HTTPException) as exc:
_require_superadmin({"role": "admin"})
assert exc.value.status_code == 403
def test_require_superadmin_allows():
_require_superadmin({"role": "superadmin"})
def test_get_capability_matrix_requires_superadmin():
with pytest.raises(HTTPException) as exc:
get_capability_matrix(session={"role": "trainer"})
assert exc.value.status_code == 403

View File

@ -0,0 +1,138 @@
"""Kontingent-Bypass über Capability-Grants (kein Env-Hardcoding)."""
import pytest
from club_quota_bypass import (
QUOTA_BYPASS_ALL,
is_club_feature_quota_bypassed,
quota_bypass_access,
)
from club_features import consume_club_feature, probe_club_feature_access
class _FakeCur:
def __init__(self, *, portal_grants=None, profile_grants=None):
self._portal_grants = set(portal_grants or ())
self._profile_grants = set(profile_grants or ())
self._last_sql = ""
self._last_params = ()
def execute(self, sql, params=None):
self._last_sql = (sql or "").lower()
self._last_params = params or ()
def fetchone(self):
if "portal_role_capability_grants" in self._last_sql:
role, cap = self._last_params[:2]
if (role, cap) in self._portal_grants:
return {"1": 1}
if "profile_capability_grants" in self._last_sql:
pid, cap = self._last_params[:2]
if (int(pid), cap) in self._profile_grants:
return {"1": 1}
return None
def fetchall(self):
return []
def test_portal_role_grant_bypasses():
cur = _FakeCur(portal_grants={("superadmin", QUOTA_BYPASS_ALL)})
assert is_club_feature_quota_bypassed(
cur, profile_id=1, portal_role="superadmin", feature_id="ai_calls"
)
assert not is_club_feature_quota_bypassed(
cur, profile_id=1, portal_role="trainer", feature_id="ai_calls"
)
def test_profile_grant_bypasses():
cap = "platform.club_quota.bypass.ai_calls"
cur = _FakeCur(profile_grants={(42, cap)})
assert is_club_feature_quota_bypassed(
cur, profile_id=42, portal_role="trainer", feature_id="ai_calls"
)
def test_probe_superadmin_bypasses_enforce(monkeypatch):
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
class Cur:
def execute(self, *a, **k):
pass
def fetchone(self):
return {"1": 1}
def fetchall(self):
return []
monkeypatch.setattr("club_features.get_cursor", lambda c: Cur())
monkeypatch.setattr(
"club_features.get_effective_club_plan",
lambda cur, club_id: "free",
)
monkeypatch.setattr(
"club_quota_bypass._portal_role_has_grant",
lambda cur, role, cap: role == "superadmin",
)
monkeypatch.setattr(
"club_quota_bypass._profile_has_grant",
lambda cur, pid, cap: False,
)
access = probe_club_feature_access(
feature_id="ai_calls",
action="suggest",
club_id=5,
profile_id=1,
portal_role="superadmin",
conn=object(),
)
assert access["allowed"] is True
assert access["reason"] == "capability_quota_bypass"
def test_consume_skips_for_bypass_grant(monkeypatch):
calls = []
monkeypatch.setattr(
"club_features.increment_club_feature_usage",
lambda *a, **k: calls.append(1),
)
class Cur:
def execute(self, *a, **k):
pass
def fetchone(self):
return {"1": 1}
def fetchall(self):
return []
monkeypatch.setattr("club_features.get_cursor", lambda c: Cur())
monkeypatch.setattr(
"club_quota_bypass._portal_role_has_grant",
lambda cur, role, cap: role == "superadmin",
)
monkeypatch.setattr(
"club_quota_bypass._profile_has_grant",
lambda cur, pid, cap: False,
)
consume_club_feature(
feature_id="ai_calls",
club_id=9,
profile_id=1,
portal_role="superadmin",
conn=object(),
)
assert calls == []
def test_quota_bypass_access_shape():
row = quota_bypass_access(feature_id="ai_calls", club_id=3, plan_id="free")
assert row["platform_exempt"] is True
assert row["limit"] is None
assert row["allowed"] is True
assert row["reason"] == "capability_quota_bypass"

View File

@ -0,0 +1,65 @@
"""Tests für club_feature_logger und probe (ohne DB)."""
import json
from club_feature_logger import feature_usage_logger, log_club_feature_usage
from club_features import club_feature_enforcement_enabled, probe_club_feature_access
def test_log_club_feature_usage_json(monkeypatch):
captured = []
monkeypatch.setattr(feature_usage_logger, "info", lambda msg: captured.append(msg))
access = {
"allowed": False,
"limit": 0,
"used": 3,
"remaining": 0,
"reason": "feature_disabled",
"plan_id": "free",
}
log_club_feature_usage(
club_id=12,
profile_id=7,
feature_id="ai_calls",
action="suggest",
access=access,
endpoint="POST /exercises/ai/suggest",
)
assert captured
payload = json.loads(captured[-1])
assert payload["club_id"] == 12
assert payload["profile_id"] == 7
assert payload["feature"] == "ai_calls"
assert payload["allowed"] is False
assert payload["plan_id"] == "free"
assert payload["phase"] == "probe"
def test_probe_no_club_context_logs_without_db(monkeypatch):
# CI/Deploy kann CLUB_FEATURE_ENFORCE=1 setzen — Test prüft Probe-Modus (kein Hard-Block).
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
logged = []
def _capture(**kwargs):
logged.append(kwargs)
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", _capture)
access = probe_club_feature_access(
feature_id="ai_calls",
action="suggest",
club_id=None,
profile_id=1,
endpoint="test",
)
assert access["reason"] == "no_club_context"
assert access["allowed"] is True
assert len(logged) == 1
assert logged[0]["club_id"] is None
def test_club_feature_enforcement_default_off(monkeypatch):
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
assert club_feature_enforcement_enabled() is False
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
assert club_feature_enforcement_enabled() is True

View File

@ -0,0 +1,225 @@
"""M5: ai_calls Verbrauch + Hard-Block (CLUB_FEATURE_ENFORCE)."""
import pytest
from fastapi import HTTPException
from club_features import (
club_feature_enforcement_enabled,
consume_club_feature,
consume_club_feature_with_usage,
merge_feature_usage_into_response,
probe_club_feature_access,
)
def _fake_cur():
class C:
def execute(self, *a, **k):
pass
def fetchone(self):
return None
return C()
def test_probe_blocks_when_enforce_and_limit_exceeded(monkeypatch):
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
monkeypatch.setattr(
"club_quota_bypass.is_club_feature_quota_bypassed",
lambda *a, **k: False,
)
monkeypatch.setattr(
"club_features.check_club_feature_access",
lambda club_id, feature_id, conn=None: {
"allowed": False,
"limit": 0,
"used": 0,
"remaining": 0,
"reason": "feature_disabled",
"plan_id": "free",
},
)
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
with pytest.raises(HTTPException) as exc:
probe_club_feature_access(
feature_id="ai_calls",
action="suggest",
club_id=12,
profile_id=3,
endpoint="POST /exercises/ai/suggest",
conn=object(),
)
assert exc.value.status_code == 403
assert "ai_calls" in str(exc.value.detail)
def test_probe_allows_when_enforce_off(monkeypatch):
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "0")
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
monkeypatch.setattr(
"club_quota_bypass.is_club_feature_quota_bypassed",
lambda *a, **k: False,
)
monkeypatch.setattr(
"club_features.check_club_feature_access",
lambda club_id, feature_id, conn=None: {
"allowed": False,
"limit": 0,
"used": 0,
"remaining": 0,
"reason": "feature_disabled",
"plan_id": "free",
},
)
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
access = probe_club_feature_access(
feature_id="ai_calls",
action="suggest",
club_id=12,
profile_id=3,
conn=object(),
)
assert access["allowed"] is False
def test_consume_skips_without_club_id(monkeypatch):
calls = []
def _inc(*args, **kwargs):
calls.append(1)
monkeypatch.setattr("club_features.increment_club_feature_usage", _inc)
consume_club_feature(feature_id="ai_calls", club_id=None, profile_id=1)
assert calls == []
def test_consume_logs_usage_after_increment(monkeypatch):
logs = []
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
monkeypatch.setattr(
"club_quota_bypass.is_club_feature_quota_bypassed",
lambda *a, **k: False,
)
monkeypatch.setattr("club_features.increment_club_feature_usage", lambda *a, **k: None)
monkeypatch.setattr(
"club_features.check_club_feature_access",
lambda club_id, feature_id, conn=None: {
"allowed": True,
"used": 1,
"limit": 30,
"remaining": 29,
"plan_id": "club",
"reason": "within_limit",
},
)
monkeypatch.setattr(
"club_feature_logger.log_club_feature_usage",
lambda **kwargs: logs.append(kwargs),
)
consume_club_feature(
feature_id="ai_calls",
club_id=5,
profile_id=9,
portal_role="trainer",
action="suggest",
conn=object(),
)
assert len(logs) == 1
assert logs[0]["phase"] == "consume"
assert logs[0]["feature_id"] == "ai_calls"
assert logs[0]["club_id"] == 5
def test_consume_increments_once_per_call(monkeypatch):
calls = []
def _inc(club_id, feature_id, **kwargs):
calls.append((club_id, feature_id, kwargs.get("action")))
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
monkeypatch.setattr(
"club_quota_bypass.is_club_feature_quota_bypassed",
lambda *a, **k: False,
)
monkeypatch.setattr("club_features.increment_club_feature_usage", _inc)
monkeypatch.setattr(
"club_features.check_club_feature_access",
lambda club_id, feature_id, conn=None: {
"allowed": True,
"used": 1,
"limit": 30,
"remaining": 29,
"plan_id": "club",
"reason": "within_limit",
},
)
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
consume_club_feature(
feature_id="ai_calls",
club_id=5,
profile_id=9,
portal_role="trainer",
action="suggest",
conn=object(),
)
assert calls == [(5, "ai_calls", "suggest")]
def test_merge_feature_usage_into_response():
out = merge_feature_usage_into_response(
{"ok": True},
{"ai_calls": {"used": 3, "limit": 30}},
)
assert out["ok"] is True
assert out["feature_usage"]["ai_calls"]["used"] == 3
assert merge_feature_usage_into_response({"x": 1}, None) == {"x": 1}
def test_consume_with_usage_returns_snapshot(monkeypatch):
monkeypatch.setattr("club_features.get_cursor", lambda conn: _fake_cur())
monkeypatch.setattr(
"club_quota_bypass.is_club_feature_quota_bypassed",
lambda *a, **k: False,
)
monkeypatch.setattr("club_features.consume_club_feature", lambda **kwargs: None)
monkeypatch.setattr(
"club_features.club_feature_usage_for_api",
lambda cur, **kwargs: {"used": 4, "limit": 30, "allowed": True},
)
usage = consume_club_feature_with_usage(
feature_id="ai_calls",
club_id=7,
profile_id=1,
portal_role="trainer",
action="suggest",
cur=_fake_cur(),
conn=object(),
)
assert usage["ai_calls"]["used"] == 4
def test_probe_blocks_no_club_context_when_enforce(monkeypatch):
monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1")
monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None)
with pytest.raises(HTTPException) as exc:
probe_club_feature_access(
feature_id="ai_calls",
action="suggest",
club_id=None,
profile_id=3,
endpoint="POST /exercises/ai/suggest",
)
assert exc.value.status_code == 403
assert "Vereinskontext" in str(exc.value.detail)
def test_club_feature_enforcement_env_default_off(monkeypatch):
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
assert club_feature_enforcement_enabled() is False

View File

@ -0,0 +1,27 @@
"""Unit-Tests für club_features (ohne DB)."""
from datetime import datetime, timezone
from club_features import _calculate_next_reset, _normalize_limit
def test_normalize_limit_none_and_negative():
assert _normalize_limit(None) is None
assert _normalize_limit(-1) is None
assert _normalize_limit(0) == 0
assert _normalize_limit(50) == 50
def test_calculate_next_reset_never():
assert _calculate_next_reset("never") is None
def test_calculate_next_reset_monthly_december():
ref = datetime(2026, 12, 15, 12, 0, tzinfo=timezone.utc)
nxt = _calculate_next_reset("monthly", now=ref)
assert nxt == datetime(2027, 1, 1, tzinfo=timezone.utc)
def test_calculate_next_reset_monthly_mid_year():
ref = datetime(2026, 6, 6, 12, 0, tzinfo=timezone.utc)
nxt = _calculate_next_reset("monthly", now=ref)
assert nxt == datetime(2026, 7, 1, tzinfo=timezone.utc)

View File

@ -0,0 +1,79 @@
"""Tests für GET /me/entitlements Zusammenstellung."""
from datetime import datetime, timezone
from entitlements import _serialize_reset_at, build_me_entitlements
from tenant_context import TenantContext
def test_serialize_reset_at():
dt = datetime(2026, 7, 1, tzinfo=timezone.utc)
assert _serialize_reset_at(dt) == "2026-07-01T00:00:00+00:00"
assert _serialize_reset_at(None) is None
def test_build_me_entitlements_no_club(monkeypatch):
monkeypatch.setattr(
"entitlements.resolve_capabilities_map",
lambda cur, tenant, club_id=None: {"exercises.read": False},
)
tenant = TenantContext(
profile_id=1,
global_role="user",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
account_state="verified_pending_club",
)
out = build_me_entitlements(object(), tenant)
assert out["account_state"] == "verified_pending_club"
assert out["club_id"] is None
assert out["features"] == {}
assert out["capabilities"]["exercises.read"] is False
def test_build_me_entitlements_with_club(monkeypatch):
monkeypatch.setattr(
"entitlements.resolve_capabilities_map",
lambda cur, tenant, club_id=None: {
"exercises.read": True,
"exercises.ai.suggest": True,
},
)
monkeypatch.setattr(
"entitlements.club_features_map",
lambda cur, club_id, conn=None: {
"plan_id": "free",
"club_id": club_id,
"features": {
"ai_calls": {
"allowed": False,
"used": 0,
"limit": 0,
"remaining": 0,
"reason": "feature_disabled",
"reset_at": None,
}
},
},
)
monkeypatch.setattr("entitlements._club_exists", lambda cur, cid: True)
monkeypatch.setattr(
"entitlements.is_club_feature_quota_bypassed",
lambda *a, **k: False,
)
tenant = TenantContext(
profile_id=3,
global_role="trainer",
effective_club_id=1,
club_ids=frozenset({1}),
memberships=[{"id": 1, "roles": ["trainer"]}],
account_state="active_member",
)
out = build_me_entitlements(object(), tenant, club_id=1)
assert out["club_id"] == 1
assert out["plan_id"] == "free"
assert out["club_roles"] == ["trainer"]
assert out["features"]["ai_calls"]["limit"] == 0
assert out["capabilities"]["exercises.ai.suggest"] is True

View File

@ -0,0 +1,80 @@
"""Tests Planungs-KI Phase D — planning_context für suggestExerciseAi."""
from planning_exercise_form_context import (
build_progression_gap_snapshot,
build_progression_path_gap_planning_context,
planning_context_prompt_variables,
sanitize_planning_context_for_ai,
)
def test_planning_context_prompt_variables_empty():
vars_ = planning_context_prompt_variables(None)
assert vars_["planning_context_json"] == "-"
assert vars_["has_planning_context"] == ""
def test_planning_context_prompt_variables_with_data():
vars_ = planning_context_prompt_variables({"source": "test", "goal_query": "Mae Geri"})
assert vars_["has_planning_context"] == "true"
assert "Mae Geri" in vars_["planning_context_json"]
def test_build_progression_path_gap_context():
ctx = build_progression_path_gap_planning_context(
goal_query="Mae Geri Perfektion",
primary_topic="Mae Geri",
progression_graph_id=3,
offer={
"source": "roadmap_unfilled",
"phase": "vertiefung",
"title_hint": "Koordination Mae Geri",
"roadmap_major_step_index": 2,
"from_title": "Schritt A",
"to_title": "Schritt B",
},
neighbor_before={"title": "Schritt A"},
neighbor_after={"title": "Schritt B"},
path_step_count=4,
major_step_count=5,
)
assert ctx["source"] == "progression_path_gap_fill"
assert ctx["roadmap_major_step_index"] == 2
assert ctx["neighbor_before_title"] == "Schritt A"
def test_sanitize_truncates_long_strings():
ctx = sanitize_planning_context_for_ai({"goal_query": "x" * 900})
assert len(ctx["goal_query"]) <= 800
def test_build_progression_gap_snapshot_includes_start_target_and_stage():
snap = build_progression_gap_snapshot(
goal_analysis={
"primary_topic": "Kumite Beinarbeit",
"start_assumption": "gleichförmige Steppbewegung",
"target_state": "explosiver Angriff mit Ausweichen",
"success_criteria": ["nachvollziehbarer Übergang"],
},
resolved_structured={"roadmap_notes": "Kindergruppe"},
stage_spec={
"learning_goal": "variable Rhythmen",
"load_profile": ["timing", "distanz"],
"success_criteria": ["Reaktion unter Druck"],
"anti_patterns": ["statisches Stehen"],
},
semantic_brief={"must_phrases": ["Beinarbeit"], "development_arc": ["grundlage", "anwendung"]},
)
assert snap["start_situation"] == "gleichförmige Steppbewegung"
assert snap["stage_learning_goal"] == "variable Rhythmen"
assert "timing" in snap["stage_load_profile"]
assert snap["roadmap_notes"] == "Kindergruppe"
def test_gap_planning_context_carries_snapshot_fields():
ctx = build_progression_path_gap_planning_context(
goal_query="Kumite Beinarbeit",
goal_analysis={"start_assumption": "Start", "target_state": "Ziel"},
stage_spec={"learning_goal": "Stufenziel", "load_profile": ["koordination"]},
)
assert ctx["start_situation"] == "Start"
assert ctx["stage_learning_goal"] == "Stufenziel"

View File

@ -1,6 +1,10 @@
"""Tests Planungs-KI Phase E3 — Lücken-Angebote und Off-Topic."""
from planning_exercise_path_ai_fill import collect_gap_fill_specs
from planning_exercise_path_qa import parse_llm_suggested_new_exercises
from planning_exercise_path_ai_fill import (
build_gap_fill_goal_text,
build_gap_fill_offer,
collect_gap_fill_specs,
)
from planning_exercise_path_qa import parse_llm_suggested_new_exercises, strip_off_topic_steps_from_path
from planning_exercise_semantics import build_semantic_brief
@ -62,3 +66,71 @@ def test_collect_gap_fill_specs_off_topic_and_unfilled():
off = next(s for s in specs if s["source"] == "off_topic")
assert off["replace_step_index"] == 2
assert off["insert_after_index"] == 1
def test_strip_off_topic_steps_from_path():
steps = [
{"exercise_id": 1, "title": "A"},
{"exercise_id": 2, "title": "B"},
{"exercise_id": 3, "title": "One Leg Squat"},
{"exercise_id": 4, "title": "D"},
]
off_topic = [{"step_index": 2, "title": "One Leg Squat", "exercise_id": 3}]
out, removed = strip_off_topic_steps_from_path(steps, off_topic)
assert len(out) == 3
assert len(removed) == 1
assert removed[0]["removed_title"] == "One Leg Squat"
assert [s["exercise_id"] for s in out] == [1, 2, 4]
def test_build_gap_fill_goal_text_includes_topic():
brief = build_semantic_brief("Mae Geri Perfektion")
text = build_gap_fill_goal_text(
goal_query="Mae Geri Perfektion",
brief=brief,
spec={"phase": "anwendung", "rationale": "Fehlt Kombinationstraining"},
step_a={"title": "Kihon"},
step_b={"title": "Kumite"},
)
assert "Mae Geri" in text or "mae geri" in text.lower()
assert "anwendung" in text
assert "Kihon" in text
def test_build_gap_fill_goal_text_includes_roadmap_snapshot():
brief = build_semantic_brief("Kumite Beinarbeit")
text = build_gap_fill_goal_text(
goal_query="Kumite Beinarbeit",
brief=brief,
spec={"phase": "vertiefung", "title_hint": "variable Rhythmen"},
step_a={"title": "Schritt A"},
step_b={"title": "Schritt B"},
roadmap_snapshot={
"start_situation": "gleichförmige Steppbewegung",
"target_state": "explosiver Angriff",
"stage_learning_goal": "variable Rhythmen und multidirektionale Kontrolle",
"stage_load_profile": ["timing", "distanz"],
"skill_hints": ["Beinarbeit"],
},
)
assert "gleichförmige Steppbewegung" in text
assert "explosiver Angriff" in text
assert "variable Rhythmen" in text
assert "timing" in text
def test_build_gap_fill_offer_exposes_context_preview():
brief = build_semantic_brief("Kumite Beinarbeit")
offer = build_gap_fill_offer(
spec={"source": "roadmap_unfilled", "phase": "vertiefung", "title_hint": "Rhythmen"},
steps=[{"title": "A"}, {"title": "B"}],
goal_query="Kumite Beinarbeit",
brief=brief,
roadmap_snapshot={
"start_situation": "Steppbewegung",
"target_state": "explosiver Angriff",
"stage_learning_goal": "variable Rhythmen",
},
)
assert offer["context_preview"]["start_situation"] == "Steppbewegung"
assert "variable Rhythmen" in offer["goal_for_ai"]

View File

@ -1,5 +1,10 @@
"""Tests Planungs-KI Phase C3/E — Pfad-Vorschläge."""
from planning_exercise_path_builder import _pick_best_path_hit, _hit_to_path_step
"""Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge."""
from planning_exercise_path_builder import (
_annotate_roadmap_step,
_hit_to_path_step,
_pick_best_path_hit,
)
from planning_progression_roadmap import MajorStep, StageSpecArtifact
def test_pick_next_path_hit_skips_used():
@ -23,3 +28,17 @@ def test_hit_to_path_step_maps_variant():
assert step["exercise_id"] == 10
assert step["variant_id"] == 7
assert step["suggested_variant_name"] == "Leicht"
def test_annotate_roadmap_step_adds_metadata():
spec = StageSpecArtifact(major_step_index=1, learning_goal="Grundstellung Mae Geri")
major = MajorStep(index=1, phase="grundlage", learning_goal=spec.learning_goal, consolidates=["m1"])
step = _annotate_roadmap_step(
{"exercise_id": 5, "title": "Test", "reasons": ["Bibliothek"]},
stage_spec=spec,
major_step=major,
)
assert step["roadmap_major_step_index"] == 1
assert step["roadmap_phase"] == "grundlage"
assert step["roadmap_match_source"] == "stage_spec"
assert any("Roadmap:" in r for r in step["reasons"])

View File

@ -1,7 +1,11 @@
"""Tests Planungs-KI Phase E — Pfad-QA."""
"""Tests Planungs-KI Phase E/F — Pfad-QA."""
from planning_exercise_path_builder import _pick_best_path_hit
from planning_exercise_semantics import build_semantic_brief
from planning_exercise_path_qa import apply_llm_path_reorder
from planning_exercise_path_qa import (
apply_llm_path_reorder,
detect_path_gaps,
is_roadmap_planned_neighbor_pair,
)
def test_pick_best_path_hit_prefers_semantic_score():
@ -62,6 +66,46 @@ def test_apply_llm_path_reorder_permutation():
assert notes
def test_is_roadmap_planned_neighbor_pair():
a = {"roadmap_match_source": "stage_spec", "roadmap_major_step_index": 1}
b = {"roadmap_match_source": "stage_spec", "roadmap_major_step_index": 2}
c = {"roadmap_match_source": "stage_spec", "roadmap_major_step_index": 4}
assert is_roadmap_planned_neighbor_pair(a, b) is True
assert is_roadmap_planned_neighbor_pair(a, c) is False
assert is_roadmap_planned_neighbor_pair({"exercise_id": 1}, b) is False
def test_detect_path_gaps_skips_roadmap_neighbors():
brief = build_semantic_brief("Mae Geri")
steps = [
{
"exercise_id": 1,
"title": "A",
"roadmap_match_source": "stage_spec",
"roadmap_major_step_index": 0,
},
{
"exercise_id": 2,
"title": "B",
"roadmap_match_source": "stage_spec",
"roadmap_major_step_index": 1,
},
]
class _FakeCur:
def execute(self, *args, **kwargs):
return None
def fetchall(self):
return []
def fetchone(self):
return {"title": "X", "summary": "", "goal": ""}
gaps = detect_path_gaps(_FakeCur(), steps, brief=brief, roadmap_first=True)
assert gaps == []
def test_apply_llm_path_reorder_invalid_ignored():
steps = [{"exercise_id": 1}, {"exercise_id": 2}]
reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]})

View File

@ -0,0 +1,247 @@
"""Tests Planungs-KI Phase F — Progressions-Roadmap Pipeline."""
from planning_progression_roadmap import (
PROMPT_SLUG_GOAL_ANALYSIS,
PROMPT_SLUG_ROADMAP,
PROMPT_SLUG_STAGE_SPEC,
PROMPT_SLUG_START_TARGET,
MajorStep,
RoadmapStructuredInput,
StageSpecArtifact,
build_goal_analysis,
build_roadmap_unfilled_gap_specs,
consolidate_micro_to_major,
develop_micro_objectives,
parse_start_target_from_goal_query,
progression_roadmap_to_api_dict,
resolve_roadmap_structured_input,
resolve_step_exercise_kind_filter,
run_progression_roadmap_pipeline,
run_start_target_resolve_only,
stage_spec_exercise_kind_filter,
stage_spec_retrieval_query,
normalize_major_steps_for_override,
roadmap_context_from_override,
RoadmapOverridePayload,
)
from planning_exercise_semantics import build_semantic_brief
KUMITE_GOAL = (
"Kumite Beinarbeit von einer gleichartigen Steppbewegung bis zur dynamischen "
"unvorhersehbaren Bewegung mit explosivartigem Angriff und ausweichen"
)
def test_run_progression_roadmap_pipeline_major_step_count():
ctx = run_progression_roadmap_pipeline(
"Von Erlernen bis zur Perfektion des Fußtritts Mae Geri",
max_steps=5,
)
assert ctx.roadmap is not None
assert len(ctx.roadmap.major_steps) == 5
assert len(ctx.roadmap.micro_objectives) >= 6
assert len(ctx.stage_specs) == 5
assert ctx.goal_analysis is not None
assert "Mae" in ctx.goal_analysis.primary_topic or "mae" in ctx.goal_analysis.primary_topic.lower()
def test_consolidate_micro_to_major_reduces_count():
brief = build_semantic_brief("Mae Geri")
ga = build_goal_analysis("Mae Geri Perfektion", brief)
micro = develop_micro_objectives(brief, goal_analysis=ga, min_count=8)
majors, notes = consolidate_micro_to_major(micro, max_steps=5)
assert len(majors) == 5
if len(micro) > 5:
assert notes
assert all(m.learning_goal for m in majors)
def test_major_steps_have_learning_goals():
ctx = run_progression_roadmap_pipeline("Mae Geri Grundlagen", max_steps=3)
for step in ctx.roadmap.major_steps:
assert step.learning_goal.strip()
assert step.consolidates
def test_stage_spec_retrieval_query_includes_learning_goal():
brief = build_semantic_brief("Mae Geri Perfektion")
spec = StageSpecArtifact(
major_step_index=1,
learning_goal="Koordination und Präzision vertiefen",
load_profile=["präzision"],
exercise_type="kihon_einzel",
)
major = MajorStep(index=1, phase="vertiefung", learning_goal=spec.learning_goal, consolidates=["m3"])
q = stage_spec_retrieval_query(
semantic_brief=brief,
goal_query="Mae Geri Perfektion",
stage_spec=spec,
major_step=major,
)
assert "vertiefung" in q.lower()
assert "Koordination" in q or "Präzision" in q
def test_stage_spec_exercise_kind_filter_maps_combination():
spec = StageSpecArtifact(major_step_index=0, exercise_type="kombination")
assert stage_spec_exercise_kind_filter(spec) == ["combination"]
assert resolve_step_exercise_kind_filter(spec, ["simple"]) == ["simple"]
def test_build_roadmap_unfilled_gap_specs():
brief = build_semantic_brief("Mae Geri")
spec = StageSpecArtifact(major_step_index=2, learning_goal="Anwendung im Partnerdrill")
major = MajorStep(index=2, phase="anwendung", learning_goal=spec.learning_goal, consolidates=["m5"])
specs = build_roadmap_unfilled_gap_specs(
unfilled_specs=[(2, spec)],
major_steps_by_index={2: major},
steps=[{"exercise_id": 1, "title": "A"}, {"exercise_id": 2, "title": "B"}],
brief=brief,
goal_query="Mae Geri",
)
assert len(specs) == 1
assert specs[0]["source"] == "roadmap_unfilled"
assert specs[0]["phase"] == "anwendung"
def test_normalize_major_steps_reindexes():
majors = normalize_major_steps_for_override(
[
MajorStep(index=9, phase="einstieg", learning_goal="Einstieg", consolidates=[]),
MajorStep(index=8, phase="perfektion", learning_goal="Ziel", consolidates=[]),
],
max_steps=5,
)
assert len(majors) == 2
assert majors[0].index == 0
assert majors[1].index == 1
def test_roadmap_context_from_override():
brief = build_semantic_brief("Mae Geri Perfektion")
override = RoadmapOverridePayload(
major_steps=[
MajorStep(index=0, phase="einstieg", learning_goal="Mae Geri Einstieg", consolidates=[]),
MajorStep(index=1, phase="grundlage", learning_goal="Stand und Hüfte", consolidates=[]),
MajorStep(index=2, phase="perfektion", learning_goal="Präzision unter Belastung", consolidates=[]),
]
)
ctx = roadmap_context_from_override(
"Mae Geri Perfektion",
max_steps=5,
semantic_brief=brief,
override=override,
)
assert ctx.pipeline_phase == "roadmap_v1_edited"
assert len(ctx.roadmap.major_steps) == 3
assert len(ctx.stage_specs) == 3
assert ctx.stage_specs[1].learning_goal == "Stand und Hüfte"
def test_api_dict_exposes_prompt_slug_catalog():
ctx = run_progression_roadmap_pipeline("Mae Geri", max_steps=3, include_llm_roadmap=False)
api = progression_roadmap_to_api_dict(ctx)
assert api["prompt_slug_catalog"]["start_target"] == PROMPT_SLUG_START_TARGET
assert api["prompt_slug_catalog"]["goal_analysis"] == PROMPT_SLUG_GOAL_ANALYSIS
assert api["prompt_slug_catalog"]["roadmap"] == PROMPT_SLUG_ROADMAP
assert api["prompt_slug_catalog"]["stage_spec"] == PROMPT_SLUG_STAGE_SPEC
assert api["prompt_slugs"] == []
def test_resolve_structured_user_overrides_regex():
brief = build_semantic_brief(KUMITE_GOAL)
structured = RoadmapStructuredInput(
start_situation="Trainer-Start explizit",
target_state="Trainer-Ziel explizit",
)
resolved, meta, llm_raw = resolve_roadmap_structured_input(
KUMITE_GOAL, structured, brief=brief, include_llm=False
)
assert llm_raw is None
assert resolved.start_situation == "Trainer-Start explizit"
assert resolved.target_state == "Trainer-Ziel explizit"
assert meta.start_source == "user"
assert meta.target_source == "user"
def test_resolve_structured_regex_fallback_without_llm():
brief = build_semantic_brief(KUMITE_GOAL)
resolved, meta, _ = resolve_roadmap_structured_input(
KUMITE_GOAL, None, brief=brief, include_llm=False
)
assert meta.start_source == "regex"
assert meta.target_source == "regex"
assert "Steppbewegung" in (resolved.start_situation or "")
assert "dynamischen" in (resolved.target_state or "")
def test_run_start_target_resolve_only_no_major_steps():
ctx = run_start_target_resolve_only(KUMITE_GOAL, include_llm_start_target=False)
assert ctx.pipeline_phase == "start_target_only"
assert ctx.roadmap is None
assert ctx.goal_analysis is not None
assert "Steppbewegung" in ctx.goal_analysis.start_assumption
assert ctx.resolved_structured is not None
def test_resolve_structured_merges_user_and_llm_notes():
brief = build_semantic_brief("Kumite Beinarbeit")
structured = RoadmapStructuredInput(roadmap_notes="Kindergruppe 1012")
resolved, meta, _ = resolve_roadmap_structured_input(
"Kumite Beinarbeit",
structured,
brief=brief,
include_llm=False,
)
assert resolved.roadmap_notes == "Kindergruppe 1012"
assert meta.notes_source == "user"
def test_parse_start_target_kumite_beinarbeit():
start, target = parse_start_target_from_goal_query(KUMITE_GOAL)
assert start is not None
assert "Steppbewegung" in start
assert target is not None
assert "dynamischen" in target
assert "Angriff" in target
def test_build_goal_analysis_uses_parsed_start_target():
brief = build_semantic_brief(KUMITE_GOAL)
ga = build_goal_analysis(KUMITE_GOAL, brief)
assert "Kumite Beinarbeit" in ga.primary_topic
assert "Steppbewegung" in ga.start_assumption
assert "dynamischen" in ga.target_state
assert "Voraussetzungen der Zielgruppe werden im Progressionsgraphen nicht analysiert" not in ga.start_assumption
def test_build_goal_analysis_structured_fields_override():
brief = build_semantic_brief("Kumite Beinarbeit")
structured = RoadmapStructuredInput(
start_situation="statische Vorwärtsbewegung im Partnerdrill",
target_state="explosiver Gegenangriff nach unvorhersehbarer Beinarbeit",
roadmap_notes="Kindergruppe 1012 Jahre",
)
ga = build_goal_analysis("Kumite Beinarbeit", brief, structured=structured)
assert ga.start_assumption == structured.start_situation
assert ga.target_state == structured.target_state
assert any("Kindergruppe" in c for c in ga.success_criteria)
def test_develop_micro_objectives_start_target_kumite():
brief = build_semantic_brief(KUMITE_GOAL)
ga = build_goal_analysis(KUMITE_GOAL, brief)
micro = develop_micro_objectives(brief, goal_analysis=ga, min_count=6)
titles = [m.title for m in micro]
assert any("Ausgang" in t for t in titles)
assert any("Ziel" in t for t in titles)
assert not any("Einstieg und Orientierung zum Thema" in t for t in titles)
def test_pipeline_kumite_major_steps_not_generic_templates():
ctx = run_progression_roadmap_pipeline(KUMITE_GOAL, max_steps=5, include_llm_roadmap=False)
goals = [s.learning_goal for s in ctx.roadmap.major_steps]
joined = " ".join(goals).lower()
assert "kumite beinarbeit" in joined
assert "steppbewegung" in joined or "ausgang" in joined
assert "dynamisch" in joined or "ziel" in joined
assert not any(g == "Grundstellung und Basisbewegung" for g in goals)

View File

@ -0,0 +1,33 @@
"""Registry-first: Modul-Registrierungen."""
import rights_registrations # noqa: F401
from rights_registry import (
CapabilityRegistration,
registered_capabilities,
registered_features,
register_capability,
)
def test_exercises_module_registers_wired_capabilities():
assert "exercises.ai.suggest" in registered_capabilities()
assert registered_capabilities()["exercises.ai.suggest"].module == "exercises"
def test_register_capability_requires_module():
try:
register_capability(
CapabilityRegistration(
id="test.no.module",
name="Test",
domain="test",
module="",
)
)
assert False, "expected ValueError"
except ValueError:
pass
def test_registered_features_include_ai_calls():
assert "ai_calls" in registered_features()
assert registered_features()["ai_calls"].module == "exercises"

View File

@ -1,18 +1,27 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.190"
BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531077"
APP_VERSION = "0.8.213"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260607087"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
"rights_registry": "1.0.0", # register_capability/feature + startup sync
"account_lifecycle": "1.1.0", # Phase A: account_onboarding_gate API-Middleware
"clubs": "0.4.2", # delete_club: Gründungsanträge → superseded
"club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
"admin_users": "1.0.0", # GET /api/admin/users
"club_features": "1.6.0",
"admin_rights": "1.1.0", # Matrix UX, Enforcement-Audit, clear club grants by capability
"capability_enforcement_audit": "1.0.0",
"club_quota_bypass": "1.0.0", # platform.club_quota.bypass* + Admin-Grants-API
"entitlements": "1.2.0", # capability_quota_bypass in Feature-Map für /me/entitlements
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen)
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
@ -28,8 +37,8 @@ MODULE_VERSIONS = {
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
"planning_exercise_suggest": "0.16.0", # E3: gap_fill_offers, Off-Topic, QA→KI-Pipeline
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.21.1", # start_target_only + reicher gap-fill planning_context
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -44,6 +53,69 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.209",
"date": "2026-06-07",
"changes": [
"F3-Polish: roadmap_first — keine Brücken zwischen Major Steps, kein LLM-Reorder.",
"Pfad-QS: Lücken nur noch bei Nicht-Roadmap-Übergängen; roadmap_qa_mode in Response.",
],
},
{
"version": "0.8.208",
"date": "2026-06-07",
"changes": [
"Phase D: planning_context an POST /exercises/ai/suggest — Prompts Migration 085.",
"Pfad-Builder + Planungs-Picker senden strukturierten Planungskontext bei KI-Neuanlage.",
"Modul planning_exercise_form_context.py; Platzhalter planning_context_json in Übungs-Prompts.",
],
},
{
"version": "0.8.207",
"date": "2026-06-07",
"changes": [
"Phase F4: Roadmap-Review — roadmap_only, roadmap_override auf progression-path-suggest.",
"UI: Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match.",
"Zwei-Schritt-Flow: Roadmap vorschlagen → Übungen matchen.",
],
},
{
"version": "0.8.206",
"date": "2026-06-07",
"changes": [
"Phase F3: roadmap_first — Bibliotheks-Match pro stage_spec/Major Step statt iterativem Pfad.",
"Gap-Angebote für unbesetzte Roadmap-Stufen (roadmap_unfilled).",
"UI: Pfad-Builder sendet roadmap_first; Übungen an Roadmap gekoppelt.",
],
},
{
"version": "0.8.205",
"date": "2026-06-07",
"changes": [
"Phase F2: Roadmap-LLM über konfigurierbare ai_prompts (078/079) — nur Slugs im Code.",
"include_llm_roadmap auf progression-path-suggest; Fallback deterministisch.",
"Response: prompt_slugs, prompt_slug_catalog, llm_*_applied.",
],
},
{
"version": "0.8.204",
"date": "2026-06-07",
"changes": [
"Planungs-KI Phase F0: Roadmap-first Architektur — planning_progression_roadmap.py (A→B→C).",
"API progression-path-suggest: include_roadmap_preview, progression_roadmap in Response.",
"Doku: PLANNING_PROGRESSION_ROADMAP_SPEC, PLANNING_KI_ROADMAP; Migration 078 Prompts.",
"UI: Didaktische Roadmap-Box im Pfad-Builder (Übergangsphase parallel zu Retrieval).",
],
},
{
"version": "0.8.203",
"date": "2026-06-07",
"changes": [
"Pfad-Builder E3-Fix: themenfremde Schritte (z. B. One Leg Squat) aus Pfad entfernen.",
"Lücken-Angebote: kein Pre-KI-Call — voller Entwurf beim Klick mit goal_for_ai-Kontext.",
"UI: Skills-Katalog im Preview, maxSteps beim Einfügen einhalten.",
],
},
{
"version": "0.8.190",
"date": "2026-05-23",

View File

@ -38,6 +38,8 @@ services:
APP_URL: "${APP_URL:-https://dev.shinkan.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
ENVIRONMENT: "${ENVIRONMENT:-development}"
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
MEDIAWIKI_USER: "${MEDIAWIKI_USER:-Jinkendo}"
MEDIAWIKI_PASSWORD: "${MEDIAWIKI_PASSWORD:-CHANGE_ME}"

View File

@ -44,6 +44,7 @@ services:
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://shinkan.jinkendo.de}"
ENVIRONMENT: "${ENVIRONMENT:-production}"
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
# MediaWiki/SMW Import — in dev-env.yml bereits gesetzt; Prod brauchte diese Zeilen ebenfalls,
# sonst: leere MEDIAWIKI_API_URL im Container → Import bricht ab (auf Test/Dev war es immer gesetzt).
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-23
**App-Version / DB-Schema:** App **`0.8.187`** (Planungs-KI Phase E2); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
**Stand:** 2026-06-07
**App-Version / DB-Schema:** App **`0.8.208`** (Planungs-KI Phase D); DB **`20260606086`** — maßgeblich **`backend/version.py`**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -106,15 +106,21 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** |
| **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** |
| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** |
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
| **F0F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
| **F3** | `roadmap_first` — Retrieval + QA lite (keine Brücken/Reorder) | ✅ **0.8.209** |
| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** |
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | ✅ **0.8.208** |
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_profiles.py`, `planning_exercise_target_pipeline.py`, `planning_exercise_progression.py` · Router `POST /api/planning/exercise-suggest`
**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`**
**Frontend:** `ExercisePickerModal` (Planung) · **`ExercisesListPageRoot`** — Schalter „Neu mit KI-Assistent“: Planungs-KI-Suche + Neuanlage-Modal (statt „+ Neu“) · `TrainingUnitEditPage``planningContext`
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`roadmap_first`, `include_roadmap_preview`)
**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box + Pfad je Major Step (`roadmap_first`) · `ExercisePickerModal` (Planung)
**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
**Offen (Qualität):** Bibliothek durchgängig mit Skills (Enrichment-Datenarbeit); manuelle Graph-Auswahl in UI; Progressionsgraph-Builder; Skill-Discovery/Framework-Pfade im Pack (P3)
**Offen (F4+):** Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
@ -249,10 +255,11 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
### Planungs-KI (priorisiert)
1. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen.
2. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
3. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
4. **E3:** KI-Vorschlag im UI direkt anlegen (Modal) · Embeddings für Freitext.
1. **Phase F2:** LLM für Roadmap (Prompts **078**) + `roadmap_first` Retrieval aus `stage_specs`.
2. **Phase F4:** Roadmap-Review UI (Major Steps editierbar vor Übungs-Match).
3. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
5. **Trainingsplanung G:** Kontext-Pack Gruppe/Historie — eigene Pipeline (`AI_PLANNING_KI_MULTISTAGE_FORECAST`); Mitai Workflow-Engine erst danach.
### Allgemein

View File

@ -0,0 +1,88 @@
# Planungs-KI — Produkt-Roadmap
**Stand:** 2026-06-07
**App-Version:** ab **0.8.204** — maßgeblich `backend/version.py`
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
**Leit-Spec:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
---
## Strategische Entscheidung (verbindlich)
1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first.
2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Thema, Schrittanzahl, optional Graph-Kanten.
3. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline später, **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0S4.
4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 23 Pipelines stabil sind.
---
## Phasen-Übersicht
| Phase | Domäne | Kurzbeschreibung | Status |
|-------|--------|------------------|--------|
| P0P2 | Übungssuche | Kontext-Pack, Hybrid-Score, LLM-Rerank | ✅ |
| AC2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
| EE3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
| **F0F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
| **F2F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0S4 | 🔲 |
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |
---
## Phase F — Progressions-Roadmap (aktiver Fokus)
### F0 — Foundation (0.8.204)
- [x] Spec `PLANNING_PROGRESSION_ROADMAP_SPEC.md`
- [x] Modul `planning_progression_roadmap.py` (Pydantic, Pipeline-Skeleton)
- [x] Migration **078** Prompt-Slugs (Zielanalyse, Roadmap)
- [x] API: `include_roadmap_preview` auf `progression-path-suggest`
- [x] Doku: HANDOVER, PLANNING_EXERCISE_SUGGEST_CONTEXT, MULTISTAGE_FORECAST
### F1 — Deterministische Roadmap
- [x] Phase A aus Semantic Brief
- [x] Phase B: `micro_objectives` aus `development_arc` + Konsolidierung auf N
- [x] Phase C: heuristische `stage_specs`
- [ ] pytest für Konsolidierung
### F2 — LLM Roadmap (0.8.205)
- [x] Prompts **078/079** in `ai_prompts` — Code nur Slugs (`PROMPT_SLUG_*`)
- [x] `include_llm_roadmap` + `load_and_render_ai_prompt` + JSON-Validierung
- [x] Deterministischer Fallback wenn Prompt/OpenRouter fehlt
- [ ] Response/UI: genutzte `prompt_slugs` sichtbar machen (Admin-Hinweis)
### F3 — roadmap-first (0.8.206)
- [x] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau
- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`)
- [x] QA/Lücken an Roadmap gekoppelt (`roadmap_first_lite`: keine Brücken/Reorder zwischen Major Steps)
### F4 — UI (0.8.207)
- [x] Roadmap-Review im `ExerciseProgressionPathBuilder`
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
- [x] API `roadmap_only` + `roadmap_override`
---
## Abhängigkeiten
| Von | Nach | Hinweis |
|-----|------|---------|
| F2 | Enrichment / Skills | Bessere Roadmap bei technikspezifischen Skills |
| F3 | F2 | LLM-Roadmap oder stabile heuristische B |
| G | F4 | Trainingsplanung kann Roadmap aus Graph referenzieren |
| H | G + F4 | Workflow-Engine lohnt bei verzweigten Planungsflows |
---
## Pflege
Bei Abschluss einer Teilphase: diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.

View File

@ -14,6 +14,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) |
| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F Roadmap-first, Abgrenzung Trainingsplanung) |
| [Progressions-Roadmap Spec](../../.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md) | Phase F: Artefakte A→B→C, API, Workflow-lite |
## Tests (E2E / Refaktor-Budget)

View File

@ -14,6 +14,8 @@
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
**Planungs-KI (parallel):** [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) — Phase **F** Roadmap-first Progressionsgraph (ab 0.8.204), unabhängig von Architektur-Phase 4 API-Split.
---
## Leitplanken (vereinbart)

View File

@ -0,0 +1,188 @@
# RBAC, Kontingente & Enforcement — Roadmap
**Stand:** 2026-06-08 · App **0.8.202** · Schema **20260606084**
**Bezüge:** `MEMBERSHIP_RBAC_DECISIONS_2026-06.md`, `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md`
Diese Roadmap bündelt **was fertig ist**, **was als Standard gilt** und **was noch fehlt** — ohne Insellösungen pro Feature.
---
## 1. Architektur-Standard (verbindlich)
### Registry-first (korrigiert 2026-06-07)
**Nicht:** vollständiger Capability-Katalog in Migration 079.
**Sondern:** Module registrieren Rechte/Kontingente bei Implementierung → `docs/working/RIGHTS_AND_FEATURES_REGISTRY.md`.
Admin-Matrix zeigt nur `capabilities.module IS NOT NULL` — keine vorgetäuschte Vollständigkeit.
### Request-Kette (Ziel)
```
Auth → Account-State → TenantContext
→ probe_capability (Recht)
→ probe_member_feature_access (Person, v2/M9 — falls Sub-Budget gesetzt)
→ probe_club_feature_access (Vereins-Kontingent)
→ Governance (Objekt)
→ Business-Logik
→ consume (Verein + Person) + merge_feature_usage_into_response
```
**v1 (aktuell):** nur Vereins-Ebene. **v2 (Phase 5b):** Prüfung und Zählung zusätzlich gegen `profile_id` aus der Session.
### Frontend-Standard
- `GET /api/me/entitlements` = einzige Quelle für Rechte + Kontingente in der UI
- `request()` synchronisiert `feature_usage` aus API-Responses automatisch (`featureUsageSync.js`)
- Keine parallelen `if (club_admin)` für **Sicherheit** (UX-Fallback nur übergangsweise)
### Admin
- **Rollen & Rechte** (`/admin/rights`): Matrix mit Klartext zuerst, technische ID darunter
- Umsetzungsstand pro Recht: `capability_enforcement_audit.py` → Feld `enforcement` in der Matrix
---
## 2. Ist-Stand nach Meilenstein
| Meilenstein | Inhalt | Status |
|-------------|--------|--------|
| **M1** | Feature-Schema, Pläne, Seeds | ✅ |
| **M2** | Feature-Probe + JSON-Log | ✅ |
| **M3** | Capabilities, Account-Lifecycle, Tenant | ✅ (Legacy parallel) |
| **M4** | `/me/entitlements`, Badge (KI) | ✅ teilweise |
| **M5** | Hard-Block + vollständiger Consume | ⚠️ `ai_calls` consume + Enforce auf Dev/Prod (0.8.202); Consume andere Features offen |
| **M6** | Admin UI Rollen & Rechte | ⚠️ Matrix + Kontingente; kein Plan-/Rollen-CRUD |
| **M7** | Vereinsgründung beantragen | ✅ Basis + Capabilities |
| **M8** | Stripe | ❌ |
| **Sync** | `feature_usage` + `request()` | ✅ |
---
## 3. Roadmap (empfohlene Reihenfolge)
### Phase 1 — Durchsetzung sichtbar machen (kurz)
| # | Paket | Lieferumfang | Aufwand |
|---|--------|--------------|---------|
| 1.1 | **Admin-Matrix UX** | Alle Rechte, Haken-Matrix, Umsetzungs-Badge | ✅ 2026-06-07 |
| 1.2 | **Doku-Sync** | Diese Roadmap, `MEMBERSHIP_RBAC` §2 | ✅ |
| 1.3 | **Audit pflegen** | Bei jedem `probe_capability``capability_enforcement_audit.py` | laufend |
### Phase 2 — Kontingente vollständig (M5)
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 2.1 | **Consume erweitern** | `exercises`, `exercise_media` nach Standard-Helfer |
| 2.2 | **Badges** | `FeatureUsageBadge` an Create/Upload, nicht nur KI |
| 2.3 | **Dev: Enforce** | `CLUB_FEATURE_ENFORCE=1` auf Dev, Free `ai_calls=0` testen | ✅ verifiziert |
| 2.4 | **Prod-Rollout** | Enforce schrittweise; Kommunikation an Vereine | ✅ Default Compose=1 |
### Phase 3 — Capabilities an alle Endpoints (C3C4)
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 3.1 | **Endpoint-Audit** | `ACCESS_LAYER_ENDPOINT_AUDIT.md` — jeder Schreib-Pfad |
| 3.2 | **probe_capability** | `exercises.update/delete`, `planning.*`, `org.*`, Medien-Bibliothek, … |
| 3.3 | **CAPABILITY_ENFORCE=1** | Nach Audit auf Dev, dann Prod |
| 3.4 | **Legacy abbauen** | `can_plan_in_club` nur noch als Fallback, dokumentiert |
### Phase 4 — Frontend auf Entitlements (Phase E)
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 4.1 | **Navigation** | Menüpunkte aus `hasCapability()` |
| 4.2 | **Buttons** | KI, Anlegen, Löschen, Planung — aus Entitlements |
| 4.3 | **Rollen-Labels** | Anzeige `club_roles` statt technischer IDs |
### Phase 5 — Admin & Produkt (M6 voll)
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 5.1 | **Pläne-CRUD** | Neue Vereinspläne anlegen, nicht nur Seed |
| 5.2 | **Systemrolle Co-Trainer** | Seed + Matrix |
### Phase 5b — Kontingent-Verteilung durch Vereinsadmins (M9, Priorität KI-Kosten)
**Ziel:** Vereins-Kontingent bleibt Plan-Ebene; **Vereinsadmin** verteilt Teilkontingente auf **einzelne Personen** (`profile_id`). Verbrauch und Hard-Block gelten **pro Person** und gegen den Vereins-Pool.
| # | Paket | Lieferumfang |
|---|--------|--------------|
| 5b.1 | **Schema** | `club_member_feature_budgets`, `club_member_feature_usage` (Migration); Events mit `profile_id` (bestehend teilweise) |
| 5b.2 | **Prüf-Kette** | `probe_capability`**Mitglieds-Budget** (`profile_id` aus Session) → Vereins-Kontingent → Governance |
| 5b.3 | **Consume** | Zählung auf Verein **und** Person; `consume_club_feature_with_usage` erweitern |
| 5b.4 | **Entitlements** | `/me/entitlements`: persönliches Budget + Vereins-Rest (z.B. `ai_calls_personal`, `ai_calls_club`) |
| 5b.5 | **Vereinsadmin-UI** | Kontingente auf Mitglieder verteilen (Liste/Formular pro Trainer); nur `club_admin` im eigenen Verein |
| 5b.6 | **Auswertung** | Admin/Superadmin: Verbrauch **je Person** einsehbar (Fairness, „Kontingent-Fresser“); Filter `profile_id` |
| 5b.7 | **Fairness-Modell** | Harte Sub-Budgets (Modell A, Tendenz): Person darf eigenes Limit nicht überschreiten, auch wenn Verein noch Rest hat |
**Erstes Feature:** `ai_calls` (OpenRouter-Kosten). Später gleiches Muster für andere registrierte Kontingente.
**Registry:** `register_member_quota_feature()` oder Erweiterung `FeatureRegistration` mit `supports_member_budget: true`.
Bezug: `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` §1.4.
### Phase 6 — Abrechnung (M8)
Stripe / Rechnung — bewusst nach funktionierendem Enforce.
---
## 4. Superadmin & Vereinsrechte (Entscheidung)
**Kurz: Superadmin braucht keine Vereinsrolle `club_admin` für die meiste Arbeit.**
| Ebene | Verhalten |
|-------|-----------|
| **Capabilities (neu)** | `admin` und `superadmin` = `platform_admin_bypass` für **alle Vereins-Capabilities** (`capabilities.py`) — unabhängig von `club_member_roles` |
| **Legacy-Helfer** | `can_plan_in_club`, `can_manage_club_org``True` für Plattform-Admin ohne Mitgliedschaft |
| **Mandant** | Aktiver Verein über `X-Active-Club-Id` / `active_club_id`**keine** Mitgliedschaft nötig für Plattform-Admin |
| **Kontingente** | Superadmin: Quota-Bypass (Capability-Grant); zählt nicht gegen Vereins-Kontingent |
| **Ausnahmen Legacy** | Einzelne Pfade prüfen noch **nur** `has_club_role(…, 'club_admin')` ohne Plattform-Bypass — z.B. Löschen von `visibility=club`-Übungen. → Phase 3 bereinigen |
**Empfehlung:** Superadmin **nicht** zwingend als `club_admin` in jeden Verein eintragen. Optional Mitgliedschaft nur für realistische Audit-Tests oder Vereinsorga-Simulation. Produktiv: Mandant per Club-Switcher wählen.
`admin` (Portal-Admin): gleicher Capability-Bypass für Vereins-Funktionen; Portal-Capabilities nur mit explizitem Grant in der Matrix.
---
## 5. Vereinsrollen-Matrix — Semantik (Admin-UI)
| Zustand | Bedeutung | UI |
|---------|-----------|-----|
| **Keine Grants** in DB | Alle aktiven Mitglieder (wenn `min_account_state` reicht) | Zellen zeigen „alle“ |
| **Mindestens ein Grant** | Nur angehakte Rollen | Checkboxen |
| **„Alle Mitglieder“** | Löscht alle Grants der Zeile | Zurück zum offenen Zustand |
Das ersetzt das frühere Formular „Vereinsrollen-Grant hinzufügen“, das nur bereits eingeschränkte Rechte sichtbar machte.
---
## 6. Offene Lücken (Checkliste)
- [ ] `CAPABILITY_ENFORCE=1` in Produktion
- [x] `CLUB_FEATURE_ENFORCE=1` auf Dev (Deploy 0.8.202 verifiziert)
- [ ] `CLUB_FEATURE_ENFORCE=1` in Produktion (nach Prod-Deploy bestätigen)
- [ ] Consume für alle Features mit Verbrauch (nicht nur `ai_calls`)
- [ ] `probe_capability` auf >90% der Schreib-Endpoints
- [ ] Frontend ohne Legacy-Rollen-Guards
- [ ] Multipart-Uploads an `featureUsageSync` anbinden
- [ ] Legacy-Löschpfade mit Plattform-Bypass harmonisieren
- [ ] **M9:** Kontingent-Verteilung Vereinsadmin → Person (`profile_id`), Prüfung + UI
- [ ] `HANDOVER.md` / `PROJECT_STATUS` Versionsstand aktualisieren
---
## 7. Referenzen
| Datei | Zweck |
|-------|--------|
| `backend/capability_enforcement_audit.py` | Matrix-Badges „angebunden / Legacy“ |
| `backend/club_features.py` | Consume-Standard |
| `frontend/src/utils/featureUsageSync.js` | Entitlements-Sync |
| `frontend/src/pages/AdminRightsPage.jsx` | Konfiguration |
**Changelog**
- 2026-06-07: Initial nach Session Rollen/Kontingente — Standard, Roadmap Phasen 16, Superadmin-Klärung, Matrix-Semantik.
- 2026-06-08: Phase 5b / M9 — Kontingent-Verteilung durch Vereinsadmins, personenbezogene Prüfung (`profile_id`); M5 Enforce Dev verifiziert.

View File

@ -0,0 +1,90 @@
# Rechte & Kontingente — Registry-first (Zielarchitektur)
**Stand:** 2026-06-07 · **Status:** verbindlich (korrigiert Katalog-first aus Migration 079)
---
## 1. Problem mit dem Katalog-first-Ansatz
Migration `079_capabilities.sql` hat **~70 Rechte vorab** in die DB geschrieben — aus einer Spekulation über die fertige App. Das ist für ein System im Aufbau **verkehrt herum**:
- Vollständige Liste ist **nicht möglich** und nicht wünschenswert
- Die Matrix **suggeriert** Funktionen, die es am Endpoint noch nicht gibt
- Module **registrieren sich nicht** — alles war manueller Seed
**Korrektur:** Registry-first — wie bei anderen Registries im Projekt (z.B. Platzhalter-Pflicht).
---
## 2. Zielbild
```
Modul implementiert Feature
→ register_capability() / register_feature() in rights_registrations/<modul>.py
→ Startup: sync_rights_registry_to_db()
→ Admin „Rollen & Rechte“ zeigt nur Einträge mit module IS NOT NULL
→ Endpoint: probe_capability + probe/consume Kontingent
```
| Achse | Registrierung | Konfiguration Admin |
|-------|---------------|---------------------|
| **Recht** | `CapabilityRegistration` | Matrix Vereins-/Portal-Rollen |
| **Kontingent** | `FeatureRegistration` | Vereinspläne / Limits |
Kein neuer Eintrag in `079`-artigen Bulk-Migrations für fachliche Rechte.
---
## 3. Implementierung (Code)
| Pfad | Rolle |
|------|--------|
| `backend/rights_registry.py` | `register_capability`, `register_feature`, `sync_rights_registry_to_db` |
| `backend/rights_registrations/*.py` | Pro Modul nur **tatsächlich verdrahtete** Rechte/Kontingente |
| `backend/main.py` | Sync nach Migrationen |
| Migration `084_rights_registry_module.sql` | Spalte `module` auf `capabilities` + `features` |
| `admin_rights.py` | Matrix-Query: `WHERE module IS NOT NULL` |
### Neues Modul anbinden (Pflicht)
1. Datei `rights_registrations/mein_modul.py` anlegen
2. `register_capability` / `register_feature` aufrufen
3. In `rights_registrations/__init__.py` importieren
4. Endpoint: `probe_capability` + ggf. `consume_club_feature_with_usage`
5. `capability_enforcement_audit.WIRED_PROBE` ergänzen
**Kein** Eintrag in `CAPABILITY_CATALOG` als Voraussetzung für DB — der Katalog wird zur **Dokumentation** der Namenskonvention, nicht zur Seed-Quelle.
---
## 4. Legacy-Katalog (079)
- Bleibt in der DB (`module IS NULL`) für Übergang / `check_capability`-Kompatibilität
- Erscheint **nicht** mehr in der Admin-Matrix
- Wird nicht erweitert — neue Rechte nur über Registry
- Langfristig: ungenutzte Seed-Zeilen deaktivieren oder archivieren
---
## 5. Aktuell registrierte Module (Start)
| Modul | Rechte | Kontingente |
|-------|--------|-------------|
| `exercises` | KI suggest/regenerate, create, media.upload | `ai_calls`, `exercises`, `exercise_media` |
| `planning_exercise_suggest` | planning.ai.* | (nutzt `ai_calls`) |
| `club_creation_requests` | Gründung + approve | — |
| `platform` | admin.access, quota.bypass | — |
Weitere Module folgen **mit ihrer Implementierung**, nicht vorher.
---
## 6. Referenzen
- `docs/working/RBAC_ENFORCEMENT_ROADMAP.md` — Enforcement nach Verdrahtung
- `MEMBERSHIP_RBAC_DECISIONS_2026-06.md` — Produktentscheidungen
- `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` — Kontingent-Semantik
**Changelog**
- 2026-06-07: Registry-first als verbindliche Korrektur; Migration 084; Pilot-Registrierungen.

View File

@ -1,4 +1,6 @@
import React, { Suspense, lazy } from 'react'
import LoginPage from './pages/LoginPage'
import { lazyWithRetry } from './utils/lazyWithRetry'
import {
RouterProvider,
createBrowserRouter,
@ -8,18 +10,20 @@ import {
Outlet,
} from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { EntitlementsProvider } from './context/EntitlementsContext'
import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext'
import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
import { isOnboardingAllowedPath, isOnboardingRestricted } from './utils/accountState'
import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import './app.css'
const LoginPage = lazy(() => import('./pages/LoginPage'))
const OnboardingPage = lazyWithRetry(() => import('./pages/OnboardingPage'))
const VerifyPage = lazy(() => import('./pages/VerifyPage'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const AccountSettingsPage = lazy(() => import('./pages/AccountSettingsPage'))
@ -51,6 +55,8 @@ 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 AdminRightsPage = lazy(() => import('./pages/AdminRightsPage'))
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
const LegalPage = lazy(() => import('./pages/LegalPage'))
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
@ -82,9 +88,12 @@ function AppRouteFallback() {
}
// Bottom Navigation (Mobile)
function Nav({ showAdminNav }) {
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
function Nav({ showAdminNav, onboardingOnly }) {
const { canShowInboxNav, inboxCount } = useOrgInbox()
const items = getMainNavItems(showAdminNav, {
showInbox: canShowInboxNav,
onboardingOnly,
})
const loc = useLocation()
const navItemActive = (pathname, item, routerIsActive) => {
@ -119,11 +128,10 @@ function Nav({ showAdminNav }) {
function ProtectedLayout() {
const { isAuthenticated, loading, user, logout } = useAuth()
const handleLogout = () => {
if (confirm('Wirklich abmelden?')) {
logout()
window.location.href = '/'
}
const handleLogout = async () => {
if (!confirm('Wirklich abmelden?')) return
await logout()
window.location.href = '/login'
}
if (loading) {
@ -146,26 +154,37 @@ function ProtectedLayout() {
return <Navigate to="/login" replace />
}
const showAdminNav = computeShowAdminNav(user)
const location = useLocation()
const onboardingOnly = isOnboardingRestricted(user)
if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) {
return <Navigate to="/onboarding" replace />
}
const showAdminNav = computeShowAdminNav(user) && !onboardingOnly
return (
<OrgInboxProvider user={user}>
<FormEditorActionsProvider>
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
<DesktopSidebar
showAdminNav={showAdminNav}
onboardingOnly={onboardingOnly}
user={user}
onLogout={handleLogout}
/>
<div className="app-shell">
<div className="app-shell__column">
<div className="app-header app-header--mobile app-header--mobile-stack">
<div className="app-header-mobile__top">
<div className="app-logo">🥋 Shinkan</div>
</div>
<ActiveClubSwitcher variant="mobile" />
{!onboardingOnly ? <ActiveClubSwitcher variant="mobile" /> : null}
</div>
<div className="app-main">
<InactiveMembershipBanner />
<Outlet />
</div>
<FormEditorBottomSlot>
<Nav showAdminNav={showAdminNav} />
<Nav showAdminNav={showAdminNav} onboardingOnly={onboardingOnly} />
</FormEditorBottomSlot>
</div>
</div>
@ -219,6 +238,7 @@ const appRouter = createBrowserRouter([
element: <ProtectedLayout />,
children: [
{ index: true, element: <Dashboard /> },
{ path: 'onboarding', element: <OnboardingPage /> },
{ path: 'profile', element: <Navigate to="/settings" replace /> },
{ path: 'settings', element: <AccountSettingsPage /> },
{ path: 'settings/system', element: <SettingsSystemInfoPage /> },
@ -264,6 +284,23 @@ const appRouter = createBrowserRouter([
</PlatformAdminRoute>
),
},
{
path: 'admin/club-creation-requests',
element: (
<PlatformAdminRoute>
<AdminClubCreationRequestsPage />
</PlatformAdminRoute>
),
},
{
path: 'admin/rights',
element: (
<PlatformAdminRoute>
<AdminRightsPage />
</PlatformAdminRoute>
),
},
{ path: 'admin/membership', element: <Navigate to="/admin/rights" replace /> },
{
path: 'admin/hierarchy',
element: (
@ -345,11 +382,13 @@ const appRouter = createBrowserRouter([
function App() {
return (
<AuthProvider>
<ToastProvider>
<Suspense fallback={<AppRouteFallback />}>
<RouterProvider router={appRouter} />
</Suspense>
</ToastProvider>
<EntitlementsProvider>
<ToastProvider>
<Suspense fallback={<AppRouteFallback />}>
<RouterProvider router={appRouter} />
</Suspense>
</ToastProvider>
</EntitlementsProvider>
</AuthProvider>
)
}

View File

@ -3,6 +3,8 @@
* Alle API-Aufrufe laufen über request() siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js).
*/
import { syncFeatureUsageFromApiResponse } from '../utils/featureUsageSync.js'
export const API_URL = import.meta.env.VITE_API_URL || ''
/** LocalStorage + Request-Header für Mandanten-Kontext */
@ -80,7 +82,9 @@ async function _fetchWithAuth(endpoint, options = {}) {
*/
export async function request(endpoint, options = {}) {
const response = await _fetchWithAuth(endpoint, options)
return response.json()
const data = await response.json()
syncFeatureUsageFromApiResponse(data)
return data
}
/** Text-Download (z. B. CSV-Export) mit gleicher Auth wie request(). */

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity, Building2, Shield } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -8,6 +8,8 @@ export default function AdminPageNav() {
const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/club-creation-requests', label: 'Vereinsgründungen', icon: Building2 },
{ to: '/admin/rights', label: 'Rollen & Rechte', icon: Shield },
{ to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },

View File

@ -6,11 +6,30 @@ import { useOrgInbox } from '../context/OrgInboxContext'
* Desktop-Dashboard: Hinweis auf offene Beitrittsanträge (nur ab 1024px sichtbar via CSS).
*/
export default function DashboardOrgInboxWidget() {
const { canAccessOrgInbox, inboxJoinRequests, inboxCount } = useOrgInbox()
const {
canShowInboxNav,
inboxJoinRequests,
inboxClubCreationRequests,
clubCreationRequestCount,
inboxCount,
} = useOrgInbox()
if (!canAccessOrgInbox) return null
if (!canShowInboxNav) return null
const preview = (inboxJoinRequests || []).slice(0, 5)
const preview = [
...(inboxClubCreationRequests || []).map((req) => ({
key: `creation-${req.id}`,
club: req.proposed_name || 'Neuer Verein',
applicant: req.applicant_name || req.applicant_email || 'Antragsteller/in',
kind: 'creation',
})),
...(inboxJoinRequests || []).map((req) => ({
key: `${req.club_id}-${req.id}`,
club: req.club_name || 'Verein',
applicant: req.applicant_name || req.applicant_email || 'Bewerber/in',
kind: 'join',
})),
].slice(0, 5)
return (
<section
@ -31,17 +50,27 @@ export default function DashboardOrgInboxWidget() {
</div>
<p className="muted dashboard-org-inbox-widget__lead">
{inboxCount === 0
? 'Keine offenen Beitrittsanträge.'
: `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`}
? 'Keine offenen Anträge.'
: [
clubCreationRequestCount > 0
? `${clubCreationRequestCount} Gründungsantrag${clubCreationRequestCount === 1 ? '' : 'e'}`
: null,
(inboxJoinRequests || []).length > 0
? `${(inboxJoinRequests || []).length} Beitrittsantrag${(inboxJoinRequests || []).length === 1 ? '' : 'e'}`
: null,
]
.filter(Boolean)
.join(' · ')}
</p>
{preview.length > 0 ? (
<ul className="dashboard-org-inbox-widget__list">
{preview.map((req) => (
<li key={`${req.club_id}-${req.id}`} className="dashboard-org-inbox-widget__item">
<span className="dashboard-org-inbox-widget__club">{req.club_name || 'Verein'}</span>
<span className="dashboard-org-inbox-widget__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
<li key={req.key} className="dashboard-org-inbox-widget__item">
<span className="dashboard-org-inbox-widget__club">
{req.kind === 'creation' ? 'Gründung: ' : ''}
{req.club}
</span>
<span className="dashboard-org-inbox-widget__applicant">{req.applicant}</span>
</li>
))}
</ul>

View File

@ -14,12 +14,16 @@ function sidebarLinkActive(pathname, item, routerIsActive) {
*/
export default function DesktopSidebar({
showAdminNav,
onboardingOnly = false,
user,
onLogout
}) {
const loc = useLocation()
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const items = getMainNavItems(showAdminNav, { showInbox: canAccessOrgInbox })
const { canShowInboxNav, inboxCount } = useOrgInbox()
const items = getMainNavItems(showAdminNav, {
showInbox: canShowInboxNav,
onboardingOnly,
})
const tier = user?.tier || ''
return (

View File

@ -21,6 +21,7 @@ export default function ExerciseAiSuggestPreviewModal({
applyLabel = 'Übung anlegen',
applyDisabled = false,
zIndex = 2000,
planningContextLines = [],
}) {
useEffect(() => {
if (!draft) return undefined
@ -86,6 +87,31 @@ export default function ExerciseAiSuggestPreviewModal({
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>{hint}</p>
{Array.isArray(planningContextLines) && planningContextLines.length > 0 ? (
<div
style={{
marginBottom: '16px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface))',
fontSize: '12px',
}}
>
<strong style={{ display: 'block', marginBottom: '6px', fontSize: '13px' }}>
Planungskontext (an KI übergeben)
</strong>
<dl style={{ margin: 0, display: 'grid', gap: '6px' }}>
{planningContextLines.map(({ label, value }) => (
<div key={label}>
<dt style={{ margin: 0, fontSize: '11px', color: 'var(--text3)' }}>{label}</dt>
<dd style={{ margin: '2px 0 0', lineHeight: 1.45, color: 'var(--text2)' }}>{value}</dd>
</div>
))}
</dl>
</div>
) : null}
<div style={{ display: 'grid', gap: '12px', marginBottom: '18px' }}>
<div>
<label className="form-label" htmlFor="ai-draft-title">

View File

@ -26,6 +26,7 @@ import {
aiPreviewToQuickCreateDraft,
} from '../utils/exerciseAiQuickCreate'
import { resolveExercisePickVariantId } from '../utils/exercisePlanningPick'
import { buildPickerPlanningContextForAi } from '../utils/planningContextForExerciseAi'
const PAGE_SIZE = 100
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
@ -707,6 +708,12 @@ export default function ExercisePickerModal({
setQuickAiError('')
setQuickCreateDraft(null)
const planningContextPayload = buildPickerPlanningContextForAi({
planningContextSummary,
planningContext,
searchQuery: planningSubmittedQuery || searchInput || aiSearchInput,
})
setQuickSaving(true)
try {
const aiRes = await api.suggestExerciseAi({
@ -717,6 +724,7 @@ export default function ExercisePickerModal({
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContextPayload || undefined,
include_summary: true,
include_skills: true,
include_instructions: true,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,67 @@
import { useEntitlements } from '../context/EntitlementsContext'
/**
* Zeigt Vereins-Kontingent für ein Feature (M4 UsageBadge).
* Unbegrenzt (limit null) nichts rendern.
*/
export default function FeatureUsageBadge({ featureId = 'ai_calls', label = 'KI-Kontingent' }) {
const { entitlements, loading, error, getFeature } = useEntitlements()
const feat = getFeature(featureId)
if (loading && !feat) {
return (
<span className="feature-usage-badge muted" style={{ fontSize: '0.8rem' }}>
{label}:
</span>
)
}
if (!feat) {
if (error) {
return (
<span
className="feature-usage-badge muted"
style={{ fontSize: '0.8rem', color: 'var(--text3)' }}
title={error}
>
{label}:
</span>
)
}
return null
}
const { used = 0, limit, remaining, allowed, platform_exempt: platformExempt, reason } = feat
if (platformExempt || reason === 'platform_exempt' || reason === 'capability_quota_bypass') {
return (
<span
className="feature-usage-badge"
style={{ fontSize: '0.8rem', color: 'var(--accent-dark)' }}
title="Plattform-Ausnahme: zählt nicht gegen das Vereins-Kontingent"
>
{label}: Plattform (unbegrenzt)
</span>
)
}
// limit === 0 (z. B. Free-Plan ai_calls) anzeigen; nur echtes Unbegrenzt (null) ausblenden
if (limit == null) return null
const tone = !allowed || remaining === 0 ? 'var(--danger)' : 'var(--text2)'
return (
<span
className="feature-usage-badge"
style={{ fontSize: '0.8rem', color: tone }}
title={
entitlements?.plan_id
? `Plan: ${entitlements.plan_id}`
: undefined
}
>
{label}: {used}/{limit}
{remaining != null ? ` (${remaining} übrig)` : ''}
</span>
)
}

View File

@ -19,6 +19,7 @@ import { stripHtmlToText } from '../../utils/htmlUtils'
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
import { useAuth } from '../../context/AuthContext'
import FeatureUsageBadge from '../FeatureUsageBadge'
import { useToast } from '../../context/ToastContext'
import {
activeClubMemberships,
@ -1653,15 +1654,18 @@ function ExerciseFormPageRoot() {
<label className="form-label" style={{ marginBottom: 0 }}>
Kurzbeschreibung
</label>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('summary')}
>
KI: Kurzfassung
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<FeatureUsageBadge featureId="ai_calls" />
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('summary')}
>
KI: Kurzfassung
</button>
</div>
</div>
<RichTextEditor
value={formData.summary}

View File

@ -31,8 +31,19 @@ function baseItems(opts = {}) {
return items
}
/** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */
/** Nav für Onboarding (ohne Vereinsmitgliedschaft). */
export function getOnboardingNavItems() {
return [
{ to: '/onboarding', label: 'Verein', shortLabel: 'Verein', end: true, Icon: Building2 },
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.', Icon: Settings },
]
}
/** @param {boolean} isAdmin @param {{ showInbox?: boolean, onboardingOnly?: boolean }} opts */
export function getMainNavItems(isAdmin, opts = {}) {
if (opts.onboardingOnly) {
return getOnboardingNavItems()
}
const showInbox = !!opts.showInbox
const icons = [
LayoutDashboard,

View File

@ -9,6 +9,7 @@ import {
} from 'react'
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
const AuthContext = createContext(null)
@ -122,15 +123,16 @@ export function AuthProvider({ children }) {
setUser(payload)
}, [])
const logout = useCallback(() => {
const logout = useCallback(async () => {
try {
await api.logout()
} catch {
/* Session lokal trotzdem beenden */
}
setUser(null)
localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
for (const key of Object.keys(sessionStorage)) {
if (key.startsWith('sj_coach_')) {
sessionStorage.removeItem(key)
}
}
clearCoachSessionStorage()
}, [])
const value = useMemo(

View File

@ -0,0 +1,109 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getMeEntitlements } from '../utils/api'
import {
getDefaultClubIdForGovernanceForms,
getResolvedActiveClubIdForUi,
} from '../utils/activeClub'
import {
registerFeatureUsageSyncHandler,
unregisterFeatureUsageSyncHandler,
} from '../utils/featureUsageSync'
import { useAuth } from './AuthContext'
const EntitlementsContext = createContext(null)
function mergeFeatureUsage(entitlements, featureUsage) {
if (!entitlements || !featureUsage) return entitlements
const features = { ...entitlements.features }
for (const [fid, row] of Object.entries(featureUsage)) {
if (row) features[fid] = { ...features[fid], ...row }
}
return { ...entitlements, features }
}
export function EntitlementsProvider({ children }) {
const { user, isAuthenticated, loading: authLoading } = useAuth()
const [entitlements, setEntitlements] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const clubId =
getResolvedActiveClubIdForUi(user) ?? getDefaultClubIdForGovernanceForms(user)
const refreshEntitlements = useCallback(async () => {
if (!isAuthenticated) {
setEntitlements(null)
setError(null)
return null
}
setLoading(true)
setError(null)
try {
const data = await getMeEntitlements(clubId)
setEntitlements(data)
return data
} catch (e) {
setEntitlements(null)
setError(e?.message || String(e))
return null
} finally {
setLoading(false)
}
}, [isAuthenticated, clubId])
const refreshEntitlementsQuiet = useCallback(async () => {
if (!isAuthenticated) return null
try {
const data = await getMeEntitlements(clubId)
setEntitlements(data)
return data
} catch {
return null
}
}, [isAuthenticated, clubId])
const applyFeatureUsageFromResponse = useCallback(
async (apiResponse) => {
if (apiResponse?.feature_usage) {
setEntitlements((prev) => mergeFeatureUsage(prev, apiResponse.feature_usage))
}
return refreshEntitlementsQuiet()
},
[refreshEntitlementsQuiet],
)
useEffect(() => {
registerFeatureUsageSyncHandler(applyFeatureUsageFromResponse)
return () => unregisterFeatureUsageSyncHandler()
}, [applyFeatureUsageFromResponse])
useEffect(() => {
if (authLoading) return
refreshEntitlements()
}, [authLoading, refreshEntitlements])
const value = useMemo(
() => ({
entitlements,
loading,
error,
refreshEntitlements,
refreshEntitlementsQuiet,
hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]),
getFeature: (featureId) => entitlements?.features?.[featureId] ?? null,
}),
[entitlements, loading, error, refreshEntitlements, refreshEntitlementsQuiet],
)
return (
<EntitlementsContext.Provider value={value}>{children}</EntitlementsContext.Provider>
)
}
export function useEntitlements() {
const ctx = useContext(EntitlementsContext)
if (!ctx) {
throw new Error('useEntitlements must be used within EntitlementsProvider')
}
return ctx
}

View File

@ -17,6 +17,11 @@ export function canAccessOrgInbox(user) {
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
}
/** Gründungsanträge freigeben — aktuell nur Superadmin (platform.club_creation.approve). */
export function canAccessClubCreationInbox(user) {
return user?.role === 'superadmin'
}
function canSeeContentReports(user) {
if (user?.role === 'admin' || user?.role === 'superadmin') return true
return activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
@ -28,8 +33,13 @@ export function notifyOrgInboxChanged() {
}
/** Eine konsistente Ladepfad-Logik für Join-Requests + Content-Reports (ein Codepfad für Mount + refresh). */
async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
const out = { items: [], contentReports: [], contentReportsError: null }
async function fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation) {
const out = {
items: [],
clubCreationRequests: [],
contentReports: [],
contentReportsError: null,
}
if (canAccess) {
try {
const data = await api.getInboxJoinRequests()
@ -38,6 +48,14 @@ async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
out.items = []
}
}
if (canAccessClubCreation) {
try {
const data = await api.listAdminClubCreationRequests()
out.clubCreationRequests = Array.isArray(data) ? data : []
} catch {
out.clubCreationRequests = []
}
}
if (canAccessReports) {
try {
const data = await api.getInboxContentReports()
@ -52,27 +70,33 @@ async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const [clubCreationRequests, setClubCreationRequests] = useState([])
const [contentReports, setContentReports] = useState([])
const [contentReportsError, setContentReportsError] = useState(null)
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const canAccessClubCreation = useMemo(() => canAccessClubCreationInbox(user), [user])
const hasInboxAccess = canAccess || canAccessReports || canAccessClubCreation
const refresh = useCallback(async () => {
if (!canAccess && !canAccessReports) {
if (!hasInboxAccess) {
setItems([])
setClubCreationRequests([])
setContentReports([])
setContentReportsError(null)
return
}
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation)
setItems(snap.items)
setClubCreationRequests(snap.clubCreationRequests)
setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}, [canAccess, canAccessReports])
}, [hasInboxAccess, canAccess, canAccessReports, canAccessClubCreation])
useEffect(() => {
if (!canAccess && !canAccessReports) {
if (!hasInboxAccess) {
setItems([])
setClubCreationRequests([])
setContentReports([])
setContentReportsError(null)
return undefined
@ -82,9 +106,10 @@ export function OrgInboxProvider({ user, children }) {
let timeoutId = null
const load = async () => {
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation)
if (cancelled) return
setItems(snap.items)
setClubCreationRequests(snap.clubCreationRequests)
setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}
@ -116,7 +141,7 @@ export function OrgInboxProvider({ user, children }) {
}
if (timeoutId != null) window.clearTimeout(timeoutId)
}
}, [canAccess, canAccessReports, user?.id])
}, [hasInboxAccess, canAccess, canAccessReports, canAccessClubCreation, user?.id])
useEffect(() => {
const onChange = () => { refresh() }
@ -124,21 +149,42 @@ export function OrgInboxProvider({ user, children }) {
return () => window.removeEventListener('shinkan:inbox-changed', onChange)
}, [refresh])
const clubCreationCount = clubCreationRequests.length
const joinCount = items.length
const value = useMemo(
() => ({
inboxJoinRequests: items,
inboxCount: items.length,
inboxClubCreationRequests: clubCreationRequests,
clubCreationRequestCount: clubCreationCount,
inboxCount: joinCount + clubCreationCount,
contentReports,
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
contentReportsError,
refreshOrgInbox: refresh,
canAccessOrgInbox: canAccess,
canAccessContentReports: canAccessReports,
canAccessClubCreationInbox: canAccessClubCreation,
canShowInboxNav: hasInboxAccess,
isSuperadmin: user?.role === 'superadmin',
isPlatformAdmin: user?.role === 'admin' || user?.role === 'superadmin',
isClubAdmin: activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')),
}),
[items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role, user?.clubs]
[
items,
clubCreationRequests,
clubCreationCount,
joinCount,
contentReports,
contentReportsError,
refresh,
canAccess,
canAccessReports,
canAccessClubCreation,
hasInboxAccess,
user?.role,
user?.clubs,
]
)
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>

View 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>
)
}

View File

@ -0,0 +1,753 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
const TABS = [
{ id: 'portal', label: 'Portal-Rollen' },
{ id: 'club_roles', label: 'Vereinsrollen' },
{ id: 'bypass', label: 'Kontingent-Bypass' },
{ id: 'quotas', label: 'Vereins-Kontingente' },
]
const PORTAL_ROLE_LABEL = {
user: 'Nutzer',
trainer: 'Portal-Trainer',
admin: 'Portal-Admin',
superadmin: 'Superadmin',
}
const CLUB_ROLE_LABEL = {
club_admin: 'Vereinsadmin',
trainer: 'Trainer',
division_lead: 'Spartenleitung',
content_editor: 'Inhalte',
}
function limitInputValue(v) {
if (v === null || v === undefined) return ''
return String(v)
}
function parseLimitInput(raw, limitType) {
const s = String(raw ?? '').trim()
if (s === '' || s === '∞') return null
const n = parseInt(s, 10)
if (Number.isNaN(n)) return limitType === 'boolean' ? 0 : null
return n
}
function formatLimitHint(feature) {
if (feature.limit_type === 'boolean') return '0 = aus, 1 = an'
if (feature.reset_period === 'monthly') return 'pro Monat'
if (feature.reset_period === 'never') return 'Bestand'
return ''
}
function EnforcementBadge({ enforcement, featureConsume }) {
if (!enforcement) return null
const tone =
enforcement.implemented
? 'var(--accent-dark)'
: enforcement.level === 'legacy'
? 'var(--danger)'
: 'var(--text3)'
return (
<div style={{ marginTop: '4px', fontSize: '0.68rem', lineHeight: 1.35 }}>
<span style={{ color: tone }} title={enforcement.detail}>
{enforcement.implemented ? '● ' : '○ '}
{enforcement.label}
</span>
{featureConsume ? (
<div style={{ color: featureConsume.implemented ? 'var(--accent-dark)' : 'var(--text3)' }}>
Kontingent: {featureConsume.label}
</div>
) : null}
</div>
)
}
function CapabilityNameCell({ cap }) {
return (
<td style={{ padding: '6px', verticalAlign: 'top' }}>
<div style={{ fontWeight: 500, color: 'var(--text1)' }}>{cap.name || cap.id}</div>
<code style={{ fontSize: '0.68rem', color: 'var(--text3)' }}>{cap.id}</code>
{cap.linked_feature_id ? (
<div style={{ color: 'var(--text3)', fontSize: '0.68rem' }}>
Kontingent-ID: {cap.linked_feature_id}
</div>
) : null}
<EnforcementBadge enforcement={cap.enforcement} featureConsume={cap.feature_consume} />
</td>
)
}
function clubGrantsForCapability(capMatrix, capabilityId) {
return (capMatrix?.club_role_grants || []).filter((g) => g.capability_id === capabilityId)
}
/**
* Superadmin: Rollen Fähigkeiten (Capabilities) und Vereins-Kontingente konfigurieren.
*/
export default function AdminRightsPage() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [tab, setTab] = useState('portal')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [plansData, setPlansData] = useState({ plans: [], features: [], limits: {} })
const [limitDraft, setLimitDraft] = useState({})
const [clubSubs, setClubSubs] = useState([])
const [capMatrix, setCapMatrix] = useState(null)
const [bypassData, setBypassData] = useState(null)
const [newBypassPortal, setNewBypassPortal] = useState({ portal_role: 'helpdesk', feature_id: '' })
const [newBypassProfile, setNewBypassProfile] = useState({
profile_id: '',
feature_id: '',
reason: '',
})
const loadPlans = useCallback(async () => {
const data = await api.getAdminRightsClubPlansMatrix()
setPlansData(data)
const draft = {}
for (const plan of data.plans || []) {
draft[plan.id] = {}
for (const f of data.features || []) {
draft[plan.id][f.id] = limitInputValue(data.limits?.[plan.id]?.[f.id])
}
}
setLimitDraft(draft)
}, [])
const loadClubs = useCallback(async () => {
const rows = await api.listAdminRightsClubSubscriptions()
setClubSubs(Array.isArray(rows) ? rows : [])
}, [])
const loadCapMatrix = useCallback(async () => {
setCapMatrix(await api.getAdminRightsCapabilityMatrix())
}, [])
const loadBypass = useCallback(async () => {
setBypassData(await api.listAdminRightsQuotaBypass())
}, [])
const reloadTab = useCallback(async () => {
setError('')
if (tab === 'portal' || tab === 'club_roles') await loadCapMatrix()
else if (tab === 'bypass') await loadBypass()
else if (tab === 'quotas') await Promise.all([loadPlans(), loadClubs()])
}, [tab, loadPlans, loadClubs, loadCapMatrix, loadBypass])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
setLoading(true)
try {
await reloadTab()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, reloadTab])
const portalCapabilities = useMemo(() => {
if (!capMatrix?.capabilities) return []
return capMatrix.capabilities.filter(
(c) => c.domain === 'platform' || String(c.id).startsWith('platform.'),
)
}, [capMatrix])
const clubScopedCapabilities = useMemo(() => {
if (!capMatrix?.capabilities) return []
return capMatrix.capabilities.filter(
(c) =>
c.domain !== 'platform' &&
c.domain !== 'quota_bypass' &&
c.domain !== 'account' &&
c.domain !== 'club',
)
}, [capMatrix])
const portalGrantSet = useMemo(() => {
const s = new Set()
for (const g of capMatrix?.portal_grants || []) {
s.add(`${g.portal_role}::${g.capability_id}`)
}
return s
}, [capMatrix])
const clubGrantSet = useMemo(() => {
const s = new Set()
for (const g of capMatrix?.club_role_grants || []) {
s.add(`${g.role_code}::${g.capability_id}`)
}
return s
}, [capMatrix])
if (!isSuperadmin) return <Navigate to="/" replace />
const savePlanLimits = async (planId) => {
setBusy(true)
setError('')
try {
const limits = (plansData.features || []).map((f) => ({
feature_id: f.id,
limit_value: parseLimitInput(limitDraft[planId]?.[f.id], f.limit_type),
}))
await api.updateAdminRightsClubPlanLimits(planId, limits)
await loadPlans()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const saveClubPlan = async (clubId, planId, status) => {
setBusy(true)
setError('')
try {
await api.updateAdminRightsClubSubscription(clubId, { plan_id: planId, status })
await loadClubs()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const togglePortalGrant = async (portalRole, capabilityId, hasGrant) => {
setBusy(true)
setError('')
try {
if (hasGrant) {
await api.deleteAdminRightsPortalGrant(portalRole, capabilityId)
} else {
await api.addAdminRightsPortalGrant(portalRole, capabilityId)
}
await loadCapMatrix()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const toggleClubGrant = async (roleCode, capabilityId, hasGrant) => {
setBusy(true)
setError('')
try {
if (hasGrant) {
await api.deleteAdminRightsClubRoleGrant(roleCode, capabilityId)
} else {
await api.addAdminRightsClubRoleGrant(roleCode, capabilityId)
}
await loadCapMatrix()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const openClubCapabilityForAllMembers = async (capabilityId) => {
setBusy(true)
setError('')
try {
await api.clearAdminRightsClubCapabilityGrants(capabilityId)
await loadCapMatrix()
} catch (e) {
setError(e.message || String(e))
} finally {
setBusy(false)
}
}
const submitBypassPortal = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.addAdminRightsQuotaBypassPortal(
newBypassPortal.portal_role.trim(),
newBypassPortal.feature_id.trim() || null,
)
setNewBypassPortal({ portal_role: 'helpdesk', feature_id: '' })
await loadBypass()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}
const submitBypassProfile = async (e) => {
e.preventDefault()
const pid = parseInt(newBypassProfile.profile_id, 10)
if (!pid) {
setError('Profil-ID erforderlich')
return
}
setBusy(true)
setError('')
try {
await api.addAdminRightsQuotaBypassProfile(
pid,
newBypassProfile.feature_id.trim() || null,
newBypassProfile.reason.trim() || null,
)
setNewBypassProfile({ profile_id: '', feature_id: '', reason: '' })
await loadBypass()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}
return (
<div className="page-padding app-page">
<AdminPageNav />
<h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Rollen &amp; Rechte</h1>
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55 }}>
<strong>Rechte:</strong> Wer darf welche Funktion nutzen? Haken = Grant für diese Rolle.
<br />
<strong>Kontingente:</strong> Wie viel darf ein Verein verbrauchen (an Rechten gekoppelt).
<br />
<span style={{ fontSize: '0.85rem' }}>
Es erscheinen nur <strong>vom Modul registrierte</strong> Rechte (nicht der alte
Vollkatalog). = an API angebunden · = registriert, Endpoint fehlt noch.{' '}
<code>docs/working/RIGHTS_AND_FEATURES_REGISTRY.md</code>
</span>
</p>
<div
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '1rem' }}
role="tablist"
>
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
className={`btn ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
{error ? (
<p role="alert" style={{ color: 'var(--danger)', marginTop: '0.75rem' }}>
{error}
</p>
) : null}
{loading ? (
<p className="spinner" style={{ marginTop: '1rem' }}>
Laden
</p>
) : null}
{!loading && tab === 'portal' && capMatrix ? (
<div className="card" style={{ marginTop: '1rem', overflowX: 'auto' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
Plattform-Funktionen Anzeige primär nach Klartext. Technische ID und
Umsetzungsstand darunter.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '6px', minWidth: '220px' }}>Recht</th>
{(capMatrix.portal_roles || []).map((r) => (
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
{PORTAL_ROLE_LABEL[r] || r}
</th>
))}
</tr>
</thead>
<tbody>
{portalCapabilities.map((cap) => (
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
<CapabilityNameCell cap={cap} />
{(capMatrix.portal_roles || []).map((role) => {
const on = portalGrantSet.has(`${role}::${cap.id}`)
return (
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
<input
type="checkbox"
checked={on}
disabled={busy}
aria-label={`${cap.id} für ${role}`}
onChange={() => togglePortalGrant(role, cap.id, on)}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
) : null}
{!loading && tab === 'club_roles' && capMatrix ? (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div className="card" style={{ overflowX: 'auto' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
Vereinsrollen: Alle Rechte in der Matrix. Haken = diese Rolle hat das Recht.
Zeile mit <em>alle</em> = noch nicht rollenbeschränkt (gilt für jedes aktive Mitglied).
Erster Klick auf <em>alle</em> schränkt auf die gewählte Rolle ein; Alle Mitglieder
hebt die Einschränkung wieder auf.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '6px', minWidth: '220px' }}>Recht</th>
{(capMatrix.club_roles || []).map((r) => (
<th key={r} style={{ textAlign: 'center', padding: '6px' }}>
{CLUB_ROLE_LABEL[r] || r}
</th>
))}
<th style={{ textAlign: 'left', padding: '6px', minWidth: '100px' }}>Freigabe</th>
</tr>
</thead>
<tbody>
{clubScopedCapabilities.map((cap) => {
const restricted = clubGrantsForCapability(capMatrix, cap.id).length > 0
return (
<tr key={cap.id} style={{ borderTop: '1px solid var(--border)' }}>
<CapabilityNameCell cap={cap} />
{(capMatrix.club_roles || []).map((role) => {
const on = clubGrantSet.has(`${role}::${cap.id}`)
return (
<td key={role} style={{ textAlign: 'center', padding: '6px' }}>
{restricted ? (
<input
type="checkbox"
checked={on}
disabled={busy}
aria-label={`${cap.name} für ${CLUB_ROLE_LABEL[role] || role}`}
onChange={() => toggleClubGrant(role, cap.id, on)}
/>
) : (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
title={`Standard: alle Mitglieder. Klick = nur ${CLUB_ROLE_LABEL[role] || role}`}
style={{ fontSize: '0.7rem', padding: '2px 6px' }}
onClick={() => toggleClubGrant(role, cap.id, false)}
>
alle
</button>
)}
</td>
)
})}
<td style={{ padding: '6px', whiteSpace: 'nowrap' }}>
{restricted ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
style={{ fontSize: '0.68rem', padding: '2px 6px' }}
onClick={() => openClubCapabilityForAllMembers(cap.id)}
>
Alle Mitglieder
</button>
) : null}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
) : null}
{!loading && tab === 'bypass' && bypassData ? (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: 0 }}>
Capability <code>platform.club_quota.bypass</code> umgeht Vereins-Kontingente (z.B.
Superadmin, Helpdesk). Kein separates Rechtemodell.
</p>
<div className="card">
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Portal-Rollen</h2>
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.875rem' }}>
{(bypassData.portal_role_grants || []).map((g) => (
<li key={`${g.portal_role}-${g.capability_id}`} style={{ marginBottom: '6px' }}>
<strong>{g.portal_role}</strong> {g.capability_id}
{g.linked_feature_id ? ` (${g.linked_feature_id})` : ' (alle Features)'}
<button
type="button"
className="btn btn-secondary"
style={{ marginLeft: '8px', fontSize: '0.75rem', padding: '2px 8px' }}
disabled={busy}
onClick={async () => {
setBusy(true)
try {
await api.deleteAdminRightsQuotaBypassPortal(
g.portal_role,
g.linked_feature_id || null,
)
await loadBypass()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}}
>
Entfernen
</button>
</li>
))}
</ul>
<form onSubmit={submitBypassPortal} style={{ marginTop: '12px' }}>
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
<label style={{ flex: '1 1 120px' }}>
<span className="form-label">Portal-Rolle</span>
<input
className="form-input"
value={newBypassPortal.portal_role}
onChange={(e) =>
setNewBypassPortal((p) => ({ ...p, portal_role: e.target.value }))
}
placeholder="helpdesk"
/>
</label>
<label style={{ flex: '1 1 160px' }}>
<span className="form-label">Feature (leer = alle)</span>
<input
className="form-input"
value={newBypassPortal.feature_id}
onChange={(e) =>
setNewBypassPortal((p) => ({ ...p, feature_id: e.target.value }))
}
placeholder="ai_calls"
/>
</label>
<div style={{ alignSelf: 'flex-end' }}>
<button type="submit" className="btn btn-primary" disabled={busy}>
Grant anlegen
</button>
</div>
</div>
</form>
</div>
<div className="card">
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Einzelprofile</h2>
<ul style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.875rem' }}>
{(bypassData.profile_grants || []).map((g) => (
<li key={`${g.profile_id}-${g.capability_id}`} style={{ marginBottom: '6px' }}>
Profil #{g.profile_id} {g.profile_name ? `(${g.profile_name})` : ''} {' '}
{g.capability_id}
{g.reason ? `${g.reason}` : ''}
<button
type="button"
className="btn btn-secondary"
style={{ marginLeft: '8px', fontSize: '0.75rem', padding: '2px 8px' }}
disabled={busy}
onClick={async () => {
setBusy(true)
try {
await api.deleteAdminRightsQuotaBypassProfile(
g.profile_id,
g.linked_feature_id || null,
)
await loadBypass()
} catch (err) {
setError(err.message || String(err))
} finally {
setBusy(false)
}
}}
>
Entfernen
</button>
</li>
))}
</ul>
<form onSubmit={submitBypassProfile} style={{ marginTop: '12px' }}>
<div className="form-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
<label style={{ flex: '0 1 100px' }}>
<span className="form-label">Profil-ID</span>
<input
className="form-input"
value={newBypassProfile.profile_id}
onChange={(e) =>
setNewBypassProfile((p) => ({ ...p, profile_id: e.target.value }))
}
/>
</label>
<label style={{ flex: '1 1 120px' }}>
<span className="form-label">Feature (leer = alle)</span>
<input
className="form-input"
value={newBypassProfile.feature_id}
onChange={(e) =>
setNewBypassProfile((p) => ({ ...p, feature_id: e.target.value }))
}
/>
</label>
<label style={{ flex: '2 1 200px' }}>
<span className="form-label">Grund (optional)</span>
<input
className="form-input"
value={newBypassProfile.reason}
onChange={(e) =>
setNewBypassProfile((p) => ({ ...p, reason: e.target.value }))
}
/>
</label>
<div style={{ alignSelf: 'flex-end' }}>
<button type="submit" className="btn btn-primary" disabled={busy}>
Grant anlegen
</button>
</div>
</div>
</form>
</div>
</div>
) : null}
{!loading && tab === 'quotas' ? (
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div className="card" style={{ overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Plan-Limits</h2>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)', margin: '0 0 12px' }}>
Kontingent-Bündel pro Plan. Leeres Feld = unbegrenzt. Ersetzt keine Rollen-Grants.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px', minWidth: '140px' }}>Feature</th>
{(plansData.plans || []).map((p) => (
<th key={p.id} style={{ textAlign: 'center', padding: '8px', minWidth: '100px' }}>
<div>{p.name}</div>
<div style={{ fontWeight: 400, color: 'var(--text3)', fontSize: '0.75rem' }}>
{p.id}
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: '4px', fontSize: '0.75rem', padding: '4px 8px' }}
disabled={busy}
onClick={() => savePlanLimits(p.id)}
>
Speichern
</button>
</th>
))}
</tr>
</thead>
<tbody>
{(plansData.features || []).map((f) => (
<tr key={f.id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '8px' }}>
<strong>{f.name}</strong>
<div style={{ color: 'var(--text3)', fontSize: '0.75rem' }}>
{f.id} · {formatLimitHint(f)}
</div>
</td>
{(plansData.plans || []).map((p) => (
<td key={p.id} style={{ padding: '8px', textAlign: 'center' }}>
<input
className="form-input"
style={{ width: '72px', textAlign: 'center' }}
placeholder="∞"
value={limitDraft[p.id]?.[f.id] ?? ''}
disabled={busy}
onChange={(e) =>
setLimitDraft((prev) => ({
...prev,
[p.id]: { ...prev[p.id], [f.id]: e.target.value },
}))
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="card" style={{ overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', margin: '0 0 0.75rem' }}>Verein Plan</h2>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px' }}>Verein</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Plan</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
</tr>
</thead>
<tbody>
{clubSubs.map((row) => (
<tr key={row.club_id} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '8px' }}>
{row.club_name || `Verein #${row.club_id}`}
</td>
<td style={{ padding: '8px' }}>
<select
className="form-input"
value={row.plan_id || 'free'}
disabled={busy}
onChange={(e) =>
saveClubPlan(row.club_id, e.target.value, row.status || 'active')
}
>
{(plansData.plans || []).map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.id})
</option>
))}
</select>
</td>
<td style={{ padding: '8px' }}>
<select
className="form-input"
value={row.status || 'active'}
disabled={busy}
onChange={(e) =>
saveClubPlan(row.club_id, row.plan_id || 'free', e.target.value)
}
>
<option value="active">aktiv</option>
<option value="trial">Test</option>
<option value="past_due">überfällig</option>
<option value="cancelled">gekündigt</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
{clubSubs.length === 0 ? (
<p style={{ padding: '12px', color: 'var(--text2)', margin: 0 }}>Keine Vereine.</p>
) : null}
</div>
</div>
) : null}
</div>
)
}

View File

@ -324,11 +324,14 @@ export default function InboxPage() {
const {
canAccessOrgInbox,
canAccessContentReports,
canAccessClubCreationInbox,
canShowInboxNav,
isSuperadmin,
isPlatformAdmin,
isClubAdmin,
refreshOrgInbox,
inboxJoinRequests,
inboxClubCreationRequests,
contentReports,
contentReportCount,
contentReportsError,
@ -339,7 +342,7 @@ export default function InboxPage() {
const [showArchive, setShowArchive] = useState(false)
const load = useCallback(async () => {
if (!canAccessOrgInbox && !canAccessContentReports) {
if (!canShowInboxNav) {
setLoading(false)
return
}
@ -349,13 +352,13 @@ export default function InboxPage() {
} finally {
setLoading(false)
}
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
}, [canShowInboxNav, refreshOrgInbox])
useEffect(() => {
load()
}, [load])
if (!canAccessOrgInbox && !canAccessContentReports) {
if (!canShowInboxNav) {
return (
<div className="app-page">
<h1 className="page-title">Posteingang</h1>
@ -375,7 +378,7 @@ export default function InboxPage() {
Posteingang
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
Beitrittsanträge, Vereinsgründungen und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
@ -389,7 +392,107 @@ export default function InboxPage() {
</div>
) : (
<>
{/* Abschnitt 1: Beitrittsanträge */}
{/* Abschnitt: Vereinsgründungen (nur Superadmin) */}
{canAccessClubCreationInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Vereinsgründungen
{inboxClubCreationRequests.length > 0 && (
<span
style={{
background: 'var(--accent)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{inboxClubCreationRequests.length}
</span>
)}
</h2>
{inboxClubCreationRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Gründungsanträge.</p>
</div>
) : (
<div className="inbox-page__list">
{inboxClubCreationRequests.map((req) => (
<div key={`creation-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.proposed_name}
{req.proposed_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.proposed_abbreviation})
</span>
) : null}
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Antragsteller/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
</div>
{req.proposed_description ? (
<p className="inbox-request-card__message">{req.proposed_description}</p>
) : null}
{req.message ? (
<p className="inbox-request-card__message" style={{ fontStyle: 'italic' }}>
Nachricht: {req.message}
</p>
) : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={async () => {
if (
!confirm(
'Verein anlegen und Antragsteller als Hauptverwalter eintragen?'
)
) {
return
}
try {
await api.approveClubCreationRequest(req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Freigeben
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Gründungsantrag ablehnen?')) return
try {
await api.rejectClubCreationRequest(req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Abschnitt: Beitrittsanträge */}
{canAccessOrgInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>

View File

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

View File

@ -0,0 +1,383 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
import { resolveAccountState } from '../utils/accountState'
const joinStatusLabel = (s) =>
({
pending: 'ausstehend',
accepted: 'angenommen',
rejected: 'abgelehnt',
withdrawn: 'zurückgezogen',
})[s] || s
const creationStatusLabel = (s) =>
({
pending: 'ausstehend',
approved: 'freigegeben',
rejected: 'abgelehnt',
withdrawn: 'zurückgezogen',
superseded: 'Verein entfernt',
})[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).
*/
export default function OnboardingPage() {
const { user, checkAuth } = useAuth()
const [publicClubs, setPublicClubs] = useState([])
const [myJoinRequests, setMyJoinRequests] = useState([])
const [joinClubId, setJoinClubId] = useState('')
const [joinMessage, setJoinMessage] = useState('')
const [joinBusy, setJoinBusy] = useState(false)
const [myCreationRequests, setMyCreationRequests] = useState([])
const [createName, setCreateName] = useState('')
const [createAbbr, setCreateAbbr] = useState('')
const [createDesc, setCreateDesc] = useState('')
const [createMessage, setCreateMessage] = useState('')
const [createBusy, setCreateBusy] = useState(false)
const [error, setError] = useState('')
const [ok, setOk] = useState('')
const accountState = resolveAccountState(user)
const emailOk = accountState !== 'unverified'
const refreshJoinRequests = () => {
if (!emailOk) return
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
}
const refreshCreationRequests = () => {
if (!emailOk) return
api.getMyClubCreationRequests().then(setMyCreationRequests).catch(() => {})
}
useEffect(() => {
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {})
refreshJoinRequests()
refreshCreationRequests()
}, [user?.id, emailOk])
const memberClubIds = new Set((user?.clubs || []).map((c) => c.id))
const pendingClubIds = new Set(
myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id)
)
const joinClubChoices = publicClubs.filter(
(c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
)
const hasPendingCreation = myCreationRequests.some((r) => r.status === 'pending')
const handleCreateClub = async (e) => {
e.preventDefault()
setError('')
setOk('')
const name = (createName || '').trim()
if (!name) {
setError('Bitte einen Vereinsnamen angeben.')
return
}
setCreateBusy(true)
try {
await api.createClubCreationRequest({
proposed_name: name,
proposed_abbreviation: (createAbbr || '').trim() || undefined,
proposed_description: (createDesc || '').trim() || undefined,
message: (createMessage || '').trim() || undefined,
})
setCreateName('')
setCreateAbbr('')
setCreateDesc('')
setCreateMessage('')
refreshCreationRequests()
setOk(
'Gründungsantrag gesendet. Nach Freigabe durch den Plattform-Administrator wird dein Verein angelegt.'
)
} catch (err) {
setError(err.message || 'Antrag fehlgeschlagen.')
} finally {
setCreateBusy(false)
}
}
const handleJoin = async (e) => {
e.preventDefault()
setError('')
setOk('')
if (!joinClubId) {
setError('Bitte einen Verein auswählen.')
return
}
setJoinBusy(true)
try {
await api.createClubJoinRequest({
club_id: parseInt(joinClubId, 10),
message: (joinMessage || '').trim() || undefined,
})
setJoinMessage('')
setJoinClubId('')
refreshJoinRequests()
await checkAuth()
setOk('Antrag gesendet. Der Vereinsadmin kann ihn unter Vereinsverwaltung annehmen.')
} catch (err) {
setError(err.message || 'Antrag fehlgeschlagen.')
} finally {
setJoinBusy(false)
}
}
return (
<div className="page-padding app-page" style={{ padding: '1rem', maxWidth: '40rem' }}>
<h1 style={{ marginTop: 0, fontSize: '1.5rem' }}>Willkommen bei Shinkan</h1>
<p style={{ color: 'var(--text2)', lineHeight: 1.5, marginBottom: '1.25rem' }}>
Shinkan ist die Trainingsplanungs-Plattform für Vereine. Um Übungen, Planung und Medien zu nutzen,
brauchst du eine Mitgliedschaft in einem Verein oder du beantragst die Gründung eines neuen Vereins.
</p>
<EmailVerificationBanner profile={user} />
{!emailOk ? (
<div className="card">
<p style={{ margin: 0, color: 'var(--text2)', lineHeight: 1.5 }}>
Bitte bestätige zuerst deine E-Mail-Adresse. Danach kannst du einen Beitrittsantrag stellen.
</p>
</div>
) : (
<>
{ok ? (
<p role="status" style={{ color: 'var(--accent-dark)', marginBottom: '1rem' }}>
{ok}
</p>
) : null}
{error ? (
<p role="alert" style={{ color: 'var(--danger)', marginBottom: '1rem' }}>
{error}
</p>
) : null}
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Bestehendem Verein beitreten</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle einen Verein und sende einen Beitrittsantrag. Nach Freigabe durch den Vereinsadmin
stehen dir alle Funktionen zur Verfügung.
</p>
{myJoinRequests.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Meine Anträge</strong>
<ul
style={{
margin: '0.5rem 0 0',
paddingLeft: '1.25rem',
color: 'var(--text2)',
fontSize: '0.9rem',
}}
>
{myJoinRequests.map((r) => (
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
{r.club_name || `Verein #${r.club_id}`} {joinStatusLabel(r.status)}
{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.withdrawClubJoinRequest(r.id)
refreshJoinRequests()
} catch (err) {
setError(err.message || 'Zurückziehen fehlgeschlagen.')
}
}}
>
zurückziehen
</button>
</>
) : null}
</li>
))}
</ul>
</div>
) : null}
<form onSubmit={handleJoin}>
<label className="form-label" htmlFor="onb-join-club">
Verein
</label>
<select
id="onb-join-club"
className="form-input"
value={joinClubId}
onChange={(e) => setJoinClubId(e.target.value)}
>
<option value=""></option>
{joinClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
{c.abbreviation ? ` (${c.abbreviation})` : ''}
</option>
))}
</select>
<label className="form-label" htmlFor="onb-join-msg" style={{ marginTop: '0.75rem' }}>
Nachricht (optional)
</label>
<textarea
id="onb-join-msg"
className="form-input"
rows={2}
value={joinMessage}
onChange={(e) => setJoinMessage(e.target.value)}
/>
<button
type="submit"
className="btn btn-primary"
disabled={joinBusy}
style={{ marginTop: '0.85rem' }}
>
{joinBusy ? 'Senden…' : 'Beitritt beantragen'}
</button>
</form>
</div>
<div className="card">
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Neuen Verein gründen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Stelle einen Antrag auf Vereinsgründung. Nach Freigabe durch den Plattform-Administrator
wird der Verein mit Free-Abo angelegt und du wirst Hauptverwalter.
</p>
{myCreationRequests.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Meine Gründungsanträge</strong>
<ul
style={{
margin: '0.5rem 0 0',
paddingLeft: '1.25rem',
color: 'var(--text2)',
fontSize: '0.9rem',
}}
>
{myCreationRequests.map((r) => (
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
{r.proposed_name} {creationStatusLabel(r.status)}
{isActiveApprovedCreation(r) && 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}
{isActiveApprovedCreation(r) ? (
<>
{' '}
<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>
</>
)}
<p style={{ marginTop: '1.25rem', fontSize: '0.875rem' }}>
<Link to="/settings">Einstellungen</Link> (Passwort, Profil)
</p>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More