Compare commits

..

51 Commits

Author SHA1 Message Date
b719f85c4e Merge pull request 'Progression und additional specific text placeholders' (#58) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Reviewed-on: #58
2026-06-18 11:28:45 +02:00
ea7de64061 Merge pull request 'progression V2' (#57) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Reviewed-on: #57
2026-06-13 16:34:09 +02:00
dbc2dfacb9 Merge pull request 'Progression enhancement 3 QS stages' (#56) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 49s
Reviewed-on: #56
2026-06-12 12:24:54 +02:00
9b4d091637 Merge pull request 'Progression optimiert Phase A' (#55) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 48s
Reviewed-on: #55
2026-06-11 21:26:54 +02:00
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
50c9beb4b3 Merge pull request 'Ki und Admin Feature' (#53) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 46s
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 1m24s
Reviewed-on: #53
2026-06-06 18:01:11 +02:00
29a5db63e0 Merge pull request 'Progressionsgraphen' (#52) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 43s
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 1m24s
Reviewed-on: #52
2026-05-23 12:56:52 +02:00
6db31e7312 Merge pull request 'Implement Phase C2 Enhancements for Exercise Suggestions' (#51) from develop into main
All checks were successful
Deploy Production / 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 1m13s
Reviewed-on: #51
2026-05-23 11:43:57 +02:00
16187fbbd0 Merge pull request 'Verbesserung Suche und Neuanlage von Übungen' (#50) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 44s
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 1m17s
Reviewed-on: #50
2026-05-23 10:52:41 +02:00
9ba35dc022 Merge pull request 'Optimierung KI-Scuhe + Ki-Überarbeitungen der Übungen' (#49) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 42s
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 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #49
2026-05-23 07:54:21 +02:00
fc5748bef1 Merge pull request 'KI Übungen - MVP 0.8' (#48) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 41s
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
Reviewed-on: #48
2026-05-22 19:51:54 +02:00
3bf012a8f4 Merge pull request 'export der Fähigkeiten, KI- Admin' (#47) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #47
2026-05-22 11:56:54 +02:00
69f238d9b8 Merge pull request 'KI Implementierung (MVP) auf Übungen' (#46) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #46
2026-05-22 10:38:39 +02:00
a1b85cd865 Merge pull request 'Bug_fixes' (#45) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
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
Reviewed-on: #45
2026-05-22 07:16:32 +02:00
4720d70af0 Merge pull request 'Verbesserung UX für Übungen' (#44) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
Reviewed-on: #44
2026-05-21 15:02:15 +02:00
57c464c9f6 Merge pull request 'KPI-Scroing, Filter, etc,' (#43) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m2s
Test Suite / pytest-backend (push) Successful in 37s
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 1m16s
Reviewed-on: #43
2026-05-21 10:36:49 +02:00
d42eb3ac52 Merge pull request 'Verbesserung Darstellung Rahmenprogramme' (#42) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m22s
Reviewed-on: #42
2026-05-20 16:29:53 +02:00
f9df2d31db Merge pull request 'Fähigkeitauswahl verbessert' (#41) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 37s
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 1m21s
Reviewed-on: #41
2026-05-20 11:24:38 +02:00
1e2fdeeb0f Merge pull request 'UX Verbesserung, Navigationsspeicherung' (#40) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 37s
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 1m24s
Reviewed-on: #40
2026-05-20 10:44:00 +02:00
7450c269a5 Merge pull request 'Editor Verbesserung für Übungen' (#39) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Reviewed-on: #39
2026-05-20 06:42:07 +02:00
99a5fccaa5 Grafische Änderungen (kein Trainingsmodal). Diverse Verwaltungsdialoge
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 16s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #38
2026-05-19 14:56:41 +02:00
55d87d8d17 Merge pull request 'Fähigkeitsimport verbessert' (#37) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
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 1m12s
Reviewed-on: #37
2026-05-16 11:08:39 +02:00
b35a5ae216 Merge pull request 'SplitSession auch für Rahmenprogramme' (#36) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
Reviewed-on: #36
2026-05-16 09:02:51 +02:00
bd9cfaa6e4 Merge pull request 'Parlellsession- Plan' (#35) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Reviewed-on: #35
2026-05-15 22:04:52 +02:00
639392e133 Merge pull request 'Bug Fix Dashboard + Performance Phase 3a' (#34) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m1s
Reviewed-on: #34
2026-05-14 11:51:53 +02:00
d153a22545 Merge pull request 'Bug Fixing Kombi-Übungen - Performance Update 1 (Phase 0-2)' (#33) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 32s
Test Suite / playwright-tests (push) Successful in 58s
Reviewed-on: #33
2026-05-14 09:09:55 +02:00
81d1e9bdfd Merge pull request 'minor improvements. Darstellung, Handlung, Popups' (#32) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 59s
Reviewed-on: #32
2026-05-13 22:02:42 +02:00
3214055531 Merge pull request 'Module und Kombinationsübnungen in Version 0.8' (#31) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 54s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / playwright-tests (push) Successful in 1m9s
Reviewed-on: #31
2026-05-13 16:16:49 +02:00
4c974620d8 Merge pull request 'DGSVO Compliance update 1' (#30) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s
Reviewed-on: #30
2026-05-12 06:34:14 +02:00
3134160003 Merge pull request 'feat(admin): restrict admin access and enhance navigation for superadmins' (#29) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 26s
Reviewed-on: #29
2026-05-09 13:30:32 +02:00
b19940c997 Merge pull request 'Administration für Vereinsadmin, Sperren von Usern, Inbox, Medienmanger für alle, etc.' (#28) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 26s
Reviewed-on: #28
2026-05-09 11:01:02 +02:00
dbc9057601 Merge pull request 'feat(exercises): bump version to 0.8.65 and enhance club media copyright handling' (#27) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #27
2026-05-09 09:05:28 +02:00
6136813f60 Merge pull request 'feat(clubs): enhance co-trainer ID handling for training groups' (#26) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #26
2026-05-08 13:39:58 +02:00
d82b805ffa Merge pull request 'feat(exercises): update to version 0.8.64 and enhance inline media functionality' (#25) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 31s
Reviewed-on: #25
2026-05-08 13:28:30 +02:00
c8c40474d1 Merge pull request 'Inline Medien' (#24) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #24
2026-05-08 12:39:27 +02:00
f035b5bb0b Merge pull request 'MediaPfad extern, Upload Manager Bug Fixes' (#23) from develop into main
All checks were successful
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 25s
Reviewed-on: #23
2026-05-08 11:17:12 +02:00
696cb09bf4 Merge pull request 'Medienmanager update 1' (#22) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #22
2026-05-07 16:30:20 +02:00
33fedd5ec6 Merge pull request 'Medienmanager und Sicherheitsupdate' (#21) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 24s
Reviewed-on: #21
2026-05-07 16:00:18 +02:00
c8a08f8a94 Merge pull request 'pre-Prod Alpha' (#18) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Reviewed-on: #18
2026-05-07 10:37:21 +02:00
d04ebee1f6 Merge pull request 'UX - Filter' (#12) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
Reviewed-on: #12
2026-05-06 21:25:56 +02:00
0c1fbab0ef Merge pull request 'Mandantenfähigkeit V1.1' (#11) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 11s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 22s
Reviewed-on: #11
2026-05-05 23:11:49 +02:00
a7d68c0646 Merge pull request 'Mandantenfähigkeit V1' (#10) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 46s
Reviewed-on: #10
2026-05-05 22:34:25 +02:00
a34dc19f5d Merge pull request 'Trainingsplanung und Rahmenplanung' (#9) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 58s
Reviewed-on: #9
2026-05-05 16:05:01 +02:00
9227b98431 Merge pull request 'feat: enhance MediaWiki integration and error handling in SMW client' (#8) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 52s
Reviewed-on: #8
2026-04-29 13:56:58 +02:00
390b0ecb73 Merge pull request 'chore: clean up docker-compose files and enhance SQL migration for skills' (#7) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s
Reviewed-on: #7
2026-04-29 13:43:57 +02:00
7e7adfab54 Merge pull request 'bug fixes' (#6) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 1m29s
Reviewed-on: #6
2026-04-29 13:37:21 +02:00
bce235b9f6 Merge pull request 'feat: enhance Playwright configuration and CI workflow for E2E testing' (#5) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 4s
Reviewed-on: #5
2026-04-29 12:37:28 +02:00
5334836207 Merge pull request 'feat: enhance database migration handling and health check endpoint' (#4) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m55s
Reviewed-on: #4
2026-04-29 12:31:03 +02:00
38d78abc3b Merge pull request 'bug fix' (#3) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m59s
Reviewed-on: #3
2026-04-29 11:54:17 +02:00
a143532486 Merge pull request 'Version 0.1' (#2) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m53s
Reviewed-on: #2
2026-04-29 11:20:41 +02:00
2f54fef88e Merge pull request 'feat: update docker-compose and Nginx configuration for improved service dependencies and API routing' (#1) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m56s
Reviewed-on: #1
2026-04-29 08:52:11 +02:00
62 changed files with 113 additions and 4156 deletions

View File

@ -1,9 +0,0 @@
# Designprinzipien — verschoben
Die **Jinkendo-Produktfamilie Designprinzipien** (Index + Shinkan-Serie) liegen nicht mehr hier.
**Neuer Standort (Shinkan-Serie):** [docs/jinkendo-family/design-principles/DESIGN_PRINCIPLES_INDEX.md](../../../docs/jinkendo-family/design-principles/DESIGN_PRINCIPLES_INDEX.md)
**Mitai-Serie:** [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md)
**Abgleich:** [docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md](../../../docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md)

View File

@ -53,12 +53,7 @@ SMTP_FROM=noreply@jinkendo.de
SMTP_SSL= SMTP_SSL=
SMTP_STARTTLS= SMTP_STARTTLS=
# Produktion: false. true nur für eine bewusst leere Erstinstallation. AUTO_ADMIN_FIRST_USER=true
AUTO_ADMIN_FIRST_USER=false
# true erzwingt TOTP für Plattform-Admins beim Login. Standard ist freiwillig.
REQUIRE_ADMIN_TOTP=false
# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet.
# MEDIA_TOKEN_SECRET=
ADMIN_BOOTSTRAP_EMAILS= ADMIN_BOOTSTRAP_EMAILS=
APP_URL=https://shinkan.jinkendo.de APP_URL=https://shinkan.jinkendo.de

View File

@ -33,8 +33,6 @@ AUTH_INFRA_PREFIXES = (
"/api/auth/status", "/api/auth/status",
"/api/auth/pin", "/api/auth/pin",
"/api/auth/resend-verification", "/api/auth/resend-verification",
"/api/auth/totp",
"/api/auth/media-grant",
"/api/profiles/me", "/api/profiles/me",
"/api/me/entitlements", "/api/me/entitlements",
) )

View File

@ -12,11 +12,6 @@ from fastapi import Header, Query, HTTPException
import bcrypt import bcrypt
from db import get_db, get_cursor from db import get_db, get_cursor
from media_grant import profile_id_from_media_grant
# Fester bcrypt-Hash, damit ein unbekannter Login dieselbe Rechenzeit hat wie ein Treffer.
_LOGIN_TIMING_HASH = "$2b$12$GYdnAmeiqaZg/.5J9E4f4.xhz70piViqXIpqKCY/FY609qj65sc9i"
MAX_SESSION_DAYS = 30
def hash_pin(pin: str) -> str: def hash_pin(pin: str) -> str:
@ -38,34 +33,6 @@ def verify_pin(pin: str, stored_hash: str) -> bool:
return stored_hash == hashlib.sha256(pin.encode()).hexdigest() return stored_hash == hashlib.sha256(pin.encode()).hexdigest()
def verify_pin_for_login(pin: str, stored_hash: Optional[str]) -> bool:
"""Immer ein bcrypt-Vergleich, auch wenn das Konto fehlt (kein Zeit-Orakel)."""
if stored_hash and str(stored_hash).startswith("$2"):
return verify_pin(pin, stored_hash)
verify_pin(pin or "x", _LOGIN_TIMING_HASH)
if not stored_hash:
return False
return verify_pin(pin, stored_hash)
def session_lifetime_days(profile: Optional[dict]) -> int:
raw = profile.get("session_days") if profile else None
try:
days = int(raw) if raw is not None else MAX_SESSION_DAYS
except (TypeError, ValueError):
days = MAX_SESSION_DAYS
if days < 1:
days = 1
if days > MAX_SESSION_DAYS:
days = MAX_SESSION_DAYS
return days
def revoke_profile_sessions(cur, profile_id: int) -> None:
"""Alle Sitzungen eines Profils entfernen, inklusive Reset- und 2FA-Zwischentokens."""
cur.execute("DELETE FROM sessions WHERE profile_id = %s", (profile_id,))
def make_token() -> str: def make_token() -> str:
"""Generate a secure random token for sessions.""" """Generate a secure random token for sessions."""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
@ -84,39 +51,12 @@ def get_session(token: str):
cur.execute( cur.execute(
"SELECT s.*, p.role, p.name " "SELECT s.*, p.role, p.name "
"FROM sessions s JOIN profiles p ON s.profile_id=p.id " "FROM sessions s JOIN profiles p ON s.profile_id=p.id "
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP " "WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP",
"AND COALESCE(s.purpose, 'full') = 'full'",
(token,) (token,)
) )
return cur.fetchone() return cur.fetchone()
def get_limited_session(token: str, purpose: str):
"""Sitzung mit festem Zweck (totp_enroll, totp_challenge). Kein Fach-API-Zugriff."""
if not token or purpose not in ("totp_enroll", "totp_challenge"):
return None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT s.*, p.role, p.name, p.email "
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP "
"AND s.purpose = %s",
(token, purpose),
)
return cur.fetchone()
def _profile_session_from_id(profile_id: int) -> Optional[dict]:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id AS profile_id, role, name FROM profiles WHERE id = %s",
(profile_id,),
)
return cur.fetchone()
def require_auth(x_auth_token: Optional[str] = Header(default=None)): def require_auth(x_auth_token: Optional[str] = Header(default=None)):
""" """
FastAPI dependency - requires valid authentication. FastAPI dependency - requires valid authentication.
@ -140,8 +80,8 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
""" """
FastAPI dependency - auth via header OR query parameter. FastAPI dependency - auth via header OR query parameter.
Dateiabruf für <img>/<video>: Header-Sitzung oder kurzlebiges Medien-Token (?ssetoken). Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
Ein Sitzungstoken in der Query-Zeichenfolge wird nicht akzeptiert. Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
Usage: Usage:
@app.get("/api/photos/{id}") @app.get("/api/photos/{id}")
@ -153,13 +93,7 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
Raises: Raises:
HTTPException 401 if not authenticated HTTPException 401 if not authenticated
""" """
session = get_session(x_auth_token) if x_auth_token else None session = get_session(x_auth_token or ssetoken)
if session:
return session
profile_id = profile_id_from_media_grant(ssetoken)
if profile_id is None:
raise HTTPException(401, "Nicht eingeloggt")
session = _profile_session_from_id(profile_id)
if not session: if not session:
raise HTTPException(401, "Nicht eingeloggt") raise HTTPException(401, "Nicht eingeloggt")
return session return session

View File

@ -282,7 +282,7 @@ app.include_router(ai_skill_retrieval_admin.router)
app.include_router(exercise_enrichment_admin.router) app.include_router(exercise_enrichment_admin.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad # Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken = kurzlebiges Medien-Token, kein Sitzungstoken). # GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/ # Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media")) _media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
Path(_media_dir).mkdir(parents=True, exist_ok=True) Path(_media_dir).mkdir(parents=True, exist_ok=True)

View File

@ -1,49 +0,0 @@
"""Kurzlebige Medien-Tokens. Kein Sitzungstoken — nur für Dateiabruf per Query."""
from __future__ import annotations
import hashlib
import hmac
import os
import time
from typing import Optional
MEDIA_GRANT_TTL_SECONDS = 600
_PREFIX = "mg1"
def _key() -> bytes:
dedicated = (os.getenv("MEDIA_TOKEN_SECRET") or "").strip()
if dedicated:
return hashlib.sha256(dedicated.encode("utf-8")).digest()
material = (os.getenv("DB_PASSWORD") or "shinkan-dev-media").encode("utf-8")
return hashlib.sha256(b"shinkan-media-grant-v1|" + material).digest()
def issue_media_grant(profile_id: int, *, ttl_seconds: int = MEDIA_GRANT_TTL_SECONDS) -> tuple[str, int]:
exp = int(time.time()) + int(ttl_seconds)
payload = f"{int(profile_id)}.{exp}"
sig = hmac.new(_key(), payload.encode("ascii"), hashlib.sha256).hexdigest()
return f"{_PREFIX}.{payload}.{sig}", exp
def profile_id_from_media_grant(token: Optional[str], *, now: Optional[int] = None) -> Optional[int]:
if not token or not token.startswith(f"{_PREFIX}."):
return None
parts = token.split(".")
if len(parts) != 4:
return None
_, pid_s, exp_s, sig = parts
if not pid_s.isdigit() or not exp_s.isdigit():
return None
payload = f"{pid_s}.{exp_s}"
expected = hmac.new(_key(), payload.encode("ascii"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
return None
exp = int(exp_s)
moment = int(time.time() if now is None else now)
if exp < moment:
return None
pid = int(pid_s)
if pid < 1:
return None
return pid

View File

@ -1,29 +0,0 @@
-- Login-Sperre, Sitzungszweck (Vollzugriff / 2FA-Einrichtung / 2FA-Challenge), TOTP.
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS failed_login_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMP,
ADD COLUMN IF NOT EXISTS totp_secret TEXT,
ADD COLUMN IF NOT EXISTS totp_pending_secret TEXT,
ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) NOT NULL DEFAULT 'full',
ADD COLUMN IF NOT EXISTS failed_attempts INTEGER NOT NULL DEFAULT 0;
UPDATE sessions
SET purpose = 'password_reset'
WHERE token LIKE 'reset_%' AND purpose = 'full';
CREATE INDEX IF NOT EXISTS idx_sessions_profile_purpose ON sessions (profile_id, purpose);
CREATE TABLE IF NOT EXISTS profile_recovery_codes (
id SERIAL PRIMARY KEY,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
code_hash VARCHAR(64) NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_profile_recovery_codes_profile
ON profile_recovery_codes (profile_id);

View File

@ -13,30 +13,11 @@ from datetime import date, time, datetime
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(min_length=1, max_length=128) password: str
class LoginTotpRequest(BaseModel):
challenge_token: str = Field(min_length=8, max_length=200)
code: str = Field(min_length=6, max_length=32)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=8, max_length=128)
class TotpCodeRequest(BaseModel):
code: str = Field(min_length=6, max_length=12)
class TotpDisableRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=128)
code: str = Field(min_length=6, max_length=32)
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(min_length=1, max_length=128) password: str
name: Optional[str] = None name: Optional[str] = None
requested_club_id: Optional[int] = Field(default=None, ge=1) requested_club_id: Optional[int] = Field(default=None, ge=1)

View File

@ -33,8 +33,8 @@ def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int
expires = datetime.now() + timedelta(hours=hours_valid) expires = datetime.now() + timedelta(hours=hours_valid)
cur.execute( cur.execute(
""" """
INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose) INSERT INTO sessions (token, profile_id, expires_at, created_at)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP, 'password_reset') VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
""", """,
(f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()), (f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()),
) )

View File

@ -19,8 +19,6 @@ from planning_exercise_form_context import (
prior_path_steps_before_major, prior_path_steps_before_major,
) )
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
from planning_catalog_context import ProgressionPlanningCatalogContext
from planning_prompt_variables import catalog_exercise_hints_block
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill") _logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
@ -56,7 +54,6 @@ def _build_stage_ai_context(
step_after: Optional[Mapping[str, Any]] = None, step_after: Optional[Mapping[str, Any]] = None,
prior_steps: Optional[Sequence[Mapping[str, Any]]] = None, prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
start_situation: Optional[str] = None, start_situation: Optional[str] = None,
catalog_exercise_hints: Optional[str] = None,
) -> ExerciseFormAiPromptContext: ) -> ExerciseFormAiPromptContext:
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes).""" """KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
gap = dict(spec.get("gap") or {}) gap = dict(spec.get("gap") or {})
@ -100,9 +97,6 @@ def _build_stage_ai_context(
sketch = (spec.get("sketch") or "").strip() sketch = (spec.get("sketch") or "").strip()
if sketch and sketch != learning_goal: if sketch and sketch != learning_goal:
goal_parts.extend(["", f"Kontext: {sketch}"]) goal_parts.extend(["", f"Kontext: {sketch}"])
hints = (catalog_exercise_hints or "").strip()
if hints:
goal_parts.extend(["", "Katalog-Hinweise Übungsanlage:", hints])
goal = "\n".join(goal_parts) goal = "\n".join(goal_parts)
focus_hint = topic if brief.topic_type == "technique" else None focus_hint = topic if brief.topic_type == "technique" else None
@ -124,7 +118,6 @@ def try_suggest_ai_stage_step(
brief: PlanningSemanticBrief, brief: PlanningSemanticBrief,
spec: Mapping[str, Any], spec: Mapping[str, Any],
steps: Sequence[Mapping[str, Any]], steps: Sequence[Mapping[str, Any]],
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""KI-Vorschlag für leere Roadmap-Stufe.""" """KI-Vorschlag für leere Roadmap-Stufe."""
major_idx = spec.get("roadmap_major_step_index") major_idx = spec.get("roadmap_major_step_index")
@ -143,8 +136,6 @@ def try_suggest_ai_stage_step(
if not gap.get("learning_goal"): if not gap.get("learning_goal"):
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch") gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
ctx = _build_stage_ai_context( ctx = _build_stage_ai_context(
goal_query=goal_query, goal_query=goal_query,
brief=brief, brief=brief,
@ -152,7 +143,6 @@ def try_suggest_ai_stage_step(
step_before=step_before, step_before=step_before,
step_after=step_after, step_after=step_after,
prior_steps=prior_steps, prior_steps=prior_steps,
catalog_exercise_hints=exercise_hints or None,
) )
try: try:
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx) ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
@ -199,7 +189,6 @@ def _build_gap_ai_context(
gap: Mapping[str, Any], gap: Mapping[str, Any],
title_hint: Optional[str] = None, title_hint: Optional[str] = None,
sketch_hint: Optional[str] = None, sketch_hint: Optional[str] = None,
catalog_exercise_hints: Optional[str] = None,
) -> ExerciseFormAiPromptContext: ) -> ExerciseFormAiPromptContext:
topic = (brief.primary_topic or "Technik").strip() topic = (brief.primary_topic or "Technik").strip()
phase = gap.get("expected_phase") or "vertiefung" phase = gap.get("expected_phase") or "vertiefung"
@ -217,9 +206,6 @@ def _build_gap_ai_context(
] ]
if sketch: if sketch:
goal_parts.extend(["", f"Hinweis: {sketch}"]) goal_parts.extend(["", f"Hinweis: {sketch}"])
hints = (catalog_exercise_hints or "").strip()
if hints:
goal_parts.extend(["", "Katalog-Hinweise Übungsanlage:", hints])
goal = "\n".join(goal_parts) goal = "\n".join(goal_parts)
focus_hint = topic if brief.topic_type == "technique" else None focus_hint = topic if brief.topic_type == "technique" else None
@ -285,10 +271,8 @@ def try_suggest_ai_bridge_step(
gap: Mapping[str, Any], gap: Mapping[str, Any],
title_hint: Optional[str] = None, title_hint: Optional[str] = None,
sketch_hint: Optional[str] = None, sketch_hint: Optional[str] = None,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Ruft exercise AI suggest auf — kein Speichern in DB.""" """Ruft exercise AI suggest auf — kein Speichern in DB."""
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
ctx = _build_gap_ai_context( ctx = _build_gap_ai_context(
goal_query=goal_query, goal_query=goal_query,
brief=brief, brief=brief,
@ -297,7 +281,6 @@ def try_suggest_ai_bridge_step(
gap=gap, gap=gap,
title_hint=title_hint, title_hint=title_hint,
sketch_hint=sketch_hint, sketch_hint=sketch_hint,
catalog_exercise_hints=exercise_hints or None,
) )
g_plain = strip_html_to_plain(ctx.goal) g_plain = strip_html_to_plain(ctx.goal)
if not g_plain.strip() and not (ctx.title or "").strip(): if not g_plain.strip() and not (ctx.title or "").strip():
@ -495,7 +478,6 @@ def build_gap_fill_goal_text(
step_a: Optional[Mapping[str, Any]] = None, step_a: Optional[Mapping[str, Any]] = None,
step_b: Optional[Mapping[str, Any]] = None, step_b: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None, roadmap_snapshot: Optional[Mapping[str, Any]] = None,
catalog_exercise_hints: Optional[str] = None,
) -> str: ) -> str:
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext.""" """Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
topic = (brief.primary_topic or "Technik").strip() topic = (brief.primary_topic or "Technik").strip()
@ -573,9 +555,6 @@ def build_gap_fill_goal_text(
parts.append(f"Qualitätsprüfung: {spec['rationale']}") parts.append(f"Qualitätsprüfung: {spec['rationale']}")
if spec.get("sketch"): if spec.get("sketch"):
parts.append(f"Skizze: {spec['sketch']}") parts.append(f"Skizze: {spec['sketch']}")
hints = (catalog_exercise_hints or "").strip()
if hints:
parts.append(f"Katalog-Hinweise Übungsanlage:\n{hints}")
parts.append( parts.append(
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, " "Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. " "Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
@ -592,7 +571,6 @@ def build_gap_fill_offer(
brief: Optional[PlanningSemanticBrief] = None, brief: Optional[PlanningSemanticBrief] = None,
proposal: Optional[Mapping[str, Any]] = None, proposal: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None, roadmap_snapshot: Optional[Mapping[str, Any]] = None,
catalog_exercise_hints: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
source = spec.get("source") source = spec.get("source")
idx = int(spec.get("insert_after_index") or 0) idx = int(spec.get("insert_after_index") or 0)
@ -625,7 +603,6 @@ def build_gap_fill_offer(
step_a=step_a, step_a=step_a,
step_b=step_b, step_b=step_b,
roadmap_snapshot=enriched_snapshot or None, roadmap_snapshot=enriched_snapshot or None,
catalog_exercise_hints=catalog_exercise_hints,
) )
ctx_preview = enriched_snapshot or None ctx_preview = enriched_snapshot or None
offer: Dict[str, Any] = { offer: Dict[str, Any] = {
@ -665,7 +642,6 @@ def apply_gap_fill_after_qa(
max_ai_proposals: int = 3, max_ai_proposals: int = 3,
auto_insert_proposals: bool = False, auto_insert_proposals: bool = False,
roadmap_snapshot: Optional[Mapping[str, Any]] = None, roadmap_snapshot: Optional[Mapping[str, Any]] = None,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]: ) -> 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. Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
@ -677,7 +653,6 @@ def apply_gap_fill_after_qa(
out = list(steps) out = list(steps)
proposals: List[Dict[str, Any]] = [] proposals: List[Dict[str, Any]] = []
offers: List[Dict[str, Any]] = [] offers: List[Dict[str, Any]] = []
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
for spec in specs: for spec in specs:
source = spec.get("source") source = spec.get("source")
@ -691,7 +666,6 @@ def apply_gap_fill_after_qa(
brief=brief, brief=brief,
spec=spec, spec=spec,
steps=out, steps=out,
catalog=catalog,
) )
offer = build_gap_fill_offer( offer = build_gap_fill_offer(
spec=spec, spec=spec,
@ -700,7 +674,6 @@ def apply_gap_fill_after_qa(
brief=brief, brief=brief,
proposal=proposal, proposal=proposal,
roadmap_snapshot=roadmap_snapshot, roadmap_snapshot=roadmap_snapshot,
catalog_exercise_hints=exercise_hints or None,
) )
offers.append(offer) offers.append(offer)
if proposal and auto_insert_proposals: if proposal and auto_insert_proposals:
@ -727,7 +700,6 @@ def apply_gap_fill_after_qa(
brief=brief, brief=brief,
proposal=None, proposal=None,
roadmap_snapshot=roadmap_snapshot, roadmap_snapshot=roadmap_snapshot,
catalog_exercise_hints=exercise_hints or None,
) )
offers.append(offer) offers.append(offer)
continue continue
@ -747,7 +719,6 @@ def apply_gap_fill_after_qa(
gap=gap, gap=gap,
title_hint=str(spec.get("title_hint") or ""), title_hint=str(spec.get("title_hint") or ""),
sketch_hint=str(spec.get("sketch") or ""), sketch_hint=str(spec.get("sketch") or ""),
catalog=catalog,
) )
offer = build_gap_fill_offer( offer = build_gap_fill_offer(
@ -757,7 +728,6 @@ def apply_gap_fill_after_qa(
brief=brief, brief=brief,
proposal=proposal, proposal=proposal,
roadmap_snapshot=roadmap_snapshot, roadmap_snapshot=roadmap_snapshot,
catalog_exercise_hints=exercise_hints or None,
) )
offers.append(offer) offers.append(offer)

View File

@ -24,11 +24,9 @@ from planning_catalog_context import (
load_catalog_context_from_graph_row, load_catalog_context_from_graph_row,
merge_catalog_context_into_target, merge_catalog_context_into_target,
) )
from catalog_prompt_slots import get_rematch_guard_for_catalog
from planning_exercise_profiles import PlanningTargetProfile from planning_exercise_profiles import PlanningTargetProfile
from planning_path_qa_pipeline import run_multistage_path_qa from planning_path_qa_pipeline import run_multistage_path_qa
from planning_path_rematch import ( from planning_path_rematch import (
apply_rematch_catalog_guard,
collect_rematch_slot_indices, collect_rematch_slot_indices,
filter_rematch_slot_indices, filter_rematch_slot_indices,
prune_stripped_after_rematch, prune_stripped_after_rematch,
@ -1739,13 +1737,6 @@ def _run_roadmap_rematch_loop(
stripped_off_topic=current_stripped if round_idx == 0 else [], stripped_off_topic=current_stripped if round_idx == 0 else [],
off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [], off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [],
) )
catalog_context = _resolve_planning_catalog_context(cur, body)
rematch_guard = get_rematch_guard_for_catalog(cur, catalog_context)
slot_indices = apply_rematch_catalog_guard(
slot_indices,
rematch_reasons,
rematch_guard=rematch_guard,
)
if not slot_indices: if not slot_indices:
break break
@ -2200,7 +2191,6 @@ def _run_evaluate_only_path_qa(
max_ai_proposals=0, max_ai_proposals=0,
auto_insert_proposals=False, auto_insert_proposals=False,
roadmap_snapshot=path_roadmap_snapshot, roadmap_snapshot=path_roadmap_snapshot,
catalog=catalog_context,
) )
multistage_qa = run_multistage_path_qa( multistage_qa = run_multistage_path_qa(
@ -4304,7 +4294,6 @@ def suggest_progression_path(
max_ai_proposals=0, max_ai_proposals=0,
auto_insert_proposals=False, auto_insert_proposals=False,
roadmap_snapshot=path_roadmap_snapshot, roadmap_snapshot=path_roadmap_snapshot,
catalog=catalog_context,
) )
if roadmap_gap_offers: if roadmap_gap_offers:

View File

@ -49,10 +49,9 @@ class PlanningIntentContext:
context_notes: str = "" context_notes: str = ""
topic_type: str = "general" topic_type: str = "general"
technique_sibling_excludes: List[str] = field(default_factory=list) technique_sibling_excludes: List[str] = field(default_factory=list)
catalog_exercise_hints: Optional[str] = None
def to_api_dict(self) -> Dict[str, Any]: def to_api_dict(self) -> Dict[str, Any]:
out = { return {
"source_query": self.source_query, "source_query": self.source_query,
"primary_topic": self.primary_topic, "primary_topic": self.primary_topic,
"topic_type": self.topic_type, "topic_type": self.topic_type,
@ -62,10 +61,6 @@ class PlanningIntentContext:
"technique_sibling_excludes": self.technique_sibling_excludes[:16], "technique_sibling_excludes": self.technique_sibling_excludes[:16],
"context_notes": self.context_notes[:1200] or None, "context_notes": self.context_notes[:1200] or None,
} }
hints = (self.catalog_exercise_hints or "").strip()
if hints:
out["catalog_exercise_hints"] = hints[:2000]
return out
def build_planning_intent_context( def build_planning_intent_context(
@ -75,8 +70,6 @@ def build_planning_intent_context(
goal_analysis: Optional[Mapping[str, Any]] = None, goal_analysis: Optional[Mapping[str, Any]] = None,
extra_context: Optional[str] = None, extra_context: Optional[str] = None,
primary_topic: Optional[str] = None, primary_topic: Optional[str] = None,
cur=None,
catalog=None,
) -> PlanningIntentContext: ) -> PlanningIntentContext:
"""Intent aus Anfrage, Zielanalyse und optionalem Kontext — ohne Sonderregeln pro Thema.""" """Intent aus Anfrage, Zielanalyse und optionalem Kontext — ohne Sonderregeln pro Thema."""
ga = dict(goal_analysis or {}) ga = dict(goal_analysis or {})
@ -131,12 +124,6 @@ def build_planning_intent_context(
if line not in path_success: if line not in path_success:
path_success.insert(0, line) path_success.insert(0, line)
catalog_hints = ""
if cur is not None and catalog is not None:
from planning_prompt_variables import catalog_exercise_hints_block
catalog_hints = catalog_exercise_hints_block(cur, catalog)
return PlanningIntentContext( return PlanningIntentContext(
source_query=(goal_query or "").strip(), source_query=(goal_query or "").strip(),
primary_topic=topic, primary_topic=topic,
@ -146,7 +133,6 @@ def build_planning_intent_context(
explicit_exclusions=explicit, explicit_exclusions=explicit,
technique_sibling_excludes=siblings[:16], technique_sibling_excludes=siblings[:16],
context_notes=combined_notes[:1200], context_notes=combined_notes[:1200],
catalog_exercise_hints=catalog_hints or None,
) )

View File

@ -7,8 +7,6 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
_UNFILLED_REMATCH_REASON = "Keine passende Übung für Roadmap-Stufe"
def _slot_priority_for_rematch( def _slot_priority_for_rematch(
body, body,
@ -105,7 +103,7 @@ def collect_rematch_slot_indices(
if isinstance(item, (list, tuple)) and len(item) >= 2: if isinstance(item, (list, tuple)) and len(item) >= 2:
idx, spec = item[0], item[1] idx, spec = item[0], item[1]
midx = getattr(spec, "major_step_index", idx) midx = getattr(spec, "major_step_index", idx)
_register(int(midx), _UNFILLED_REMATCH_REASON) _register(int(midx), "Keine passende Übung für Roadmap-Stufe")
elif isinstance(item, dict): elif isinstance(item, dict):
midx = _resolve_major(item) midx = _resolve_major(item)
if midx is not None: if midx is not None:
@ -116,25 +114,6 @@ def collect_rematch_slot_indices(
return indices, reasons return indices, reasons
def apply_rematch_catalog_guard(
slot_indices: Set[int],
rematch_reasons: Mapping[int, str],
*,
rematch_guard: Optional[str],
) -> Set[int]:
"""
Bei gesetztem rematch_guard (Katalog-Slot): kein Auto-Rematch nur wegen leerer Roadmap-Stufe.
Off-Topic, explizite QS-Hinweise und Stufen-Verfeinerung bleiben aktiv.
"""
if not (rematch_guard or "").strip():
return set(slot_indices)
return {
idx
for idx in slot_indices
if str(rematch_reasons.get(idx) or "").strip() != _UNFILLED_REMATCH_REASON
}
def filter_rematch_slot_indices( def filter_rematch_slot_indices(
steps: Sequence[Mapping[str, Any]], steps: Sequence[Mapping[str, Any]],
slot_indices: Set[int], slot_indices: Set[int],
@ -393,7 +372,6 @@ def prune_stripped_after_rematch(
__all__ = [ __all__ = [
"apply_rematch_catalog_guard",
"collect_rematch_slot_indices", "collect_rematch_slot_indices",
"filter_rematch_slot_indices", "filter_rematch_slot_indices",
"prune_stripped_after_rematch", "prune_stripped_after_rematch",

View File

@ -1244,8 +1244,6 @@ def run_progression_roadmap_pipeline(
resolved.target_state, resolved.target_state,
), ),
primary_topic=goal_analysis.primary_topic, primary_topic=goal_analysis.primary_topic,
cur=cur,
catalog=catalog,
) )
heuristic_specs = build_stage_specs( heuristic_specs = build_stage_specs(

View File

@ -34,27 +34,6 @@ _PLANNING_PROMPT_VARIABLE_PROVIDERS: tuple[PlanningPromptVariableProvider, ...]
) )
def catalog_exercise_hints_block(
cur,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
*,
slug: Optional[str] = None,
) -> str:
"""Kombinierte hints_on_exercise aus aktivem Katalog-Kontext (Gap-Fill / Intent)."""
if cur is None or catalog is None:
return ""
from catalog_prompt_slots import CATALOG_KINDS, placeholder_key
merged = merge_planning_prompt_variables(cur, {}, catalog=catalog, slug=slug)
lines: list[str] = []
for cfg in CATALOG_KINDS:
key = placeholder_key(cfg.kind, "hints_on_exercise")
text = (merged.get(key) or "").strip()
if text:
lines.append(f"{cfg.label_de}: {text}")
return "\n".join(lines)
def merge_planning_prompt_variables( def merge_planning_prompt_variables(
cur, cur,
base_variables: Mapping[str, str], base_variables: Mapping[str, str],
@ -134,7 +113,6 @@ def planning_prompt_placeholder_catalog() -> dict:
__all__ = [ __all__ = [
"catalog_exercise_hints_block",
"merge_planning_prompt_variables", "merge_planning_prompt_variables",
"planning_prompt_placeholder_catalog", "planning_prompt_placeholder_catalog",
] ]

View File

@ -14,40 +14,12 @@ from email.mime.text import MIMEText
from fastapi import APIRouter, HTTPException, Header, Depends from fastapi import APIRouter, HTTPException, Header, Depends
from starlette.requests import Request from starlette.requests import Request
import ipaddress
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address
from db import get_db, get_cursor from db import get_db, get_cursor
from auth import ( from auth import hash_pin, verify_pin, make_token, require_auth
get_limited_session, from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest
get_session,
hash_pin,
make_token,
require_auth,
revoke_profile_sessions,
session_lifetime_days,
verify_pin,
verify_pin_for_login,
)
from media_grant import MEDIA_GRANT_TTL_SECONDS, issue_media_grant
from models import (
ChangePasswordRequest,
LoginRequest,
LoginTotpRequest,
PasswordResetRequest,
PasswordResetConfirm,
RegisterRequest,
TotpCodeRequest,
TotpDisableRequest,
)
from totp_auth import (
generate_recovery_codes,
generate_totp_secret,
hash_recovery_code,
otpauth_uri,
verify_totp,
)
from password_reset_mail import ( from password_reset_mail import (
insert_password_reset_session, insert_password_reset_session,
password_reset_email_body, password_reset_email_body,
@ -55,158 +27,47 @@ from password_reset_mail import (
) )
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
limiter = Limiter(key_func=get_remote_address)
LOGIN_LOCK_AFTER = 8
LOGIN_LOCK_MINUTES = 15
_ADMIN_ROLES = ("admin", "superadmin")
def _client_ip(request: Request) -> str: @router.post("/login")
"""Echte Client-IP nur, wenn der direkte Peer ein vertrauenswürdiger Proxy ist.""" @limiter.limit("30/minute")
peer = request.client.host if request.client and request.client.host else "" async def login(req: LoginRequest, request: Request):
if _is_trusted_proxy(peer): """Login with email + password."""
real = (request.headers.get("x-real-ip") or "").split(",")[0].strip() with get_db() as conn:
if real: cur = get_cursor(conn)
return real[:64] cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),))
return peer or "unknown" prof = cur.fetchone()
if not prof:
raise HTTPException(401, "Ungültige Zugangsdaten")
# Verify password
if not verify_pin(req.password, prof['pin_hash']):
raise HTTPException(401, "Ungültige Zugangsdaten")
def _is_trusted_proxy(host: str) -> bool: # Auto-upgrade from SHA256 to bcrypt
if not host: if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'):
return False new_hash = hash_pin(req.password)
try: cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id']))
ip = ipaddress.ip_address(host)
except ValueError:
return False
return ip.is_private or ip.is_loopback or ip.is_link_local
# Create session
limiter = Limiter(key_func=_client_ip)
def _admin_totp_required() -> bool:
"""Nur wenn REQUIRE_ADMIN_TOTP ausdrücklich an ist. Standard: der Nutzer entscheidet selbst."""
raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower()
return raw in ("1", "true", "yes")
def _issue_session(cur, profile_id: int, *, days: int, purpose: str = "full") -> tuple[str, datetime]:
token = make_token() token = make_token()
expires = datetime.now(timezone.utc) + timedelta(days=days if purpose == "full" else 0, minutes=0) session_days = prof.get('session_days', 30)
if purpose == "totp_challenge": expires = datetime.now() + timedelta(days=session_days)
expires = datetime.now(timezone.utc) + timedelta(minutes=5) cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
elif purpose == "totp_enroll": (token, prof['id'], expires.isoformat()))
expires = datetime.now(timezone.utc) + timedelta(minutes=30)
cur.execute(
"""
INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP, %s)
""",
(token, profile_id, expires, purpose),
)
return token, expires
def _login_payload(prof: dict, token: str, expires: datetime) -> dict:
return { return {
"token": token, "token": token,
"profile_id": prof["id"], "profile_id": prof['id'],
"email": prof.get("email"), "email": prof.get("email"),
"name": prof.get("name"), "name": prof.get("name"),
"role": prof.get("role"), "role": prof.get("role"),
"tier": prof.get("tier"), "tier": prof.get("tier"),
"expires_at": expires.isoformat(), "expires_at": expires.isoformat(),
"totp_enabled": bool(prof.get("totp_enabled")),
} }
def _record_failed_login(cur, profile_id: int) -> None:
cur.execute(
"""
UPDATE profiles
SET failed_login_count = CASE
WHEN failed_login_count + 1 >= %s THEN 0
ELSE failed_login_count + 1
END,
locked_until = CASE
WHEN failed_login_count + 1 >= %s
THEN CURRENT_TIMESTAMP + (%s * INTERVAL '1 minute')
ELSE locked_until
END
WHERE id = %s
""",
(LOGIN_LOCK_AFTER, LOGIN_LOCK_AFTER, LOGIN_LOCK_MINUTES, profile_id),
)
def _clear_login_lock(cur, profile_id: int) -> None:
cur.execute(
"UPDATE profiles SET failed_login_count = 0, locked_until = NULL WHERE id = %s",
(profile_id,),
)
@router.post("/login")
@limiter.limit("10/minute")
async def login(req: LoginRequest, request: Request):
"""Login with email + password. Bei aktivem TOTP folgt ein zweiter Schritt."""
email = req.email.lower().strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT *,
(locked_until IS NOT NULL AND locked_until > CURRENT_TIMESTAMP) AS is_locked
FROM profiles WHERE email=%s
""",
(email,),
)
prof = cur.fetchone()
password_ok = verify_pin_for_login(req.password, prof["pin_hash"] if prof else None)
if not prof or not password_ok:
if prof and not prof.get("is_locked"):
_record_failed_login(cur, prof["id"])
outcome = "invalid"
elif prof.get("is_locked"):
outcome = "locked"
else:
outcome = "ok"
if prof["pin_hash"] and not str(prof["pin_hash"]).startswith("$2"):
cur.execute(
"UPDATE profiles SET pin_hash=%s WHERE id=%s",
(hash_pin(req.password), prof["id"]),
)
_clear_login_lock(cur, prof["id"])
if outcome == "invalid":
raise HTTPException(401, "Ungültige Zugangsdaten")
if outcome == "locked":
raise HTTPException(429, "Zu viele Anmeldeversuche. Bitte später erneut versuchen.")
with get_db() as conn:
cur = get_cursor(conn)
role = (prof.get("role") or "").strip().lower()
if prof.get("totp_enabled"):
token, expires = _issue_session(cur, prof["id"], days=1, purpose="totp_challenge")
return {
"totp_required": True,
"challenge_token": token,
"expires_at": expires.isoformat(),
}
if role in _ADMIN_ROLES and _admin_totp_required() and not prof.get("totp_enabled"):
token, expires = _issue_session(cur, prof["id"], days=1, purpose="totp_enroll")
return {
"totp_enrollment_required": True,
"token": token,
"email": prof.get("email"),
"expires_at": expires.isoformat(),
}
token, expires = _issue_session(cur, prof["id"], days=session_lifetime_days(prof))
return _login_payload(prof, token, expires)
@router.post("/logout") @router.post("/logout")
def logout(x_auth_token: Optional[str]=Header(default=None)): def logout(x_auth_token: Optional[str]=Header(default=None)):
"""Logout (delete session).""" """Logout (delete session)."""
@ -233,246 +94,21 @@ def auth_status():
@router.put("/pin") @router.put("/pin")
def change_pin(req: ChangePasswordRequest, session: dict = Depends(require_auth)): def change_pin(req: dict, session: dict=Depends(require_auth)):
"""Passwort wechseln. Das aktuelle Passwort ist Pflicht; alle Sitzungen werden ersetzt.""" """Change PIN/password for current user."""
pid = session["profile_id"] pid = session['profile_id']
new_pin = req.get('pin', '')
if len(new_pin) < 8:
raise HTTPException(400, "Passwort muss mind. 8 Zeichen haben")
new_hash = hash_pin(new_pin)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
prof = cur.fetchone()
if not prof or not verify_pin(req.current_password, prof["pin_hash"]):
raise HTTPException(400, "Aktuelles Passwort ist falsch")
cur.execute(
"UPDATE profiles SET pin_hash=%s WHERE id=%s",
(hash_pin(req.new_password), pid),
)
revoke_profile_sessions(cur, int(pid))
token, expires = _issue_session(cur, int(pid), days=session_lifetime_days(prof))
return {"ok": True, "token": token, "expires_at": expires.isoformat()}
@router.post("/login/totp")
@limiter.limit("10/minute")
async def login_totp(req: LoginTotpRequest, request: Request):
"""Zweiter Login-Schritt: Authenticator-Code oder Wiederherstellungscode."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT s.*, p.role, p.name, p.email
FROM sessions s JOIN profiles p ON s.profile_id = p.id
WHERE s.token = %s AND s.expires_at > CURRENT_TIMESTAMP AND s.purpose = 'totp_challenge'
""",
(req.challenge_token,),
)
challenge = cur.fetchone()
error = None
payload = None
if not challenge:
error = "Anmeldung abgelaufen. Bitte erneut anmelden."
else:
pid = int(challenge["profile_id"])
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof.get("totp_enabled"):
error = "Anmeldung abgelaufen. Bitte erneut anmelden."
else:
code_ok = verify_totp(prof.get("totp_secret"), req.code) or _consume_recovery_code(
cur, pid, req.code
)
if not code_ok:
attempts = int(challenge.get("failed_attempts") or 0) + 1
if attempts >= 5:
cur.execute("DELETE FROM sessions WHERE token=%s", (req.challenge_token,))
else:
cur.execute(
"UPDATE sessions SET failed_attempts=%s WHERE token=%s",
(attempts, req.challenge_token),
)
error = "Ungültiger Code"
else:
revoke_profile_sessions(cur, pid)
token, expires = _issue_session(cur, pid, days=session_lifetime_days(prof))
payload = _login_payload(prof, token, expires)
if error:
raise HTTPException(401, error)
return payload
def _consume_recovery_code(cur, profile_id: int, code: str) -> bool:
digest = hash_recovery_code(code)
cur.execute(
"""
UPDATE profile_recovery_codes
SET used_at = CURRENT_TIMESTAMP
WHERE profile_id = %s AND code_hash = %s AND used_at IS NULL
RETURNING id
""",
(profile_id, digest),
)
return cur.fetchone() is not None
def _setup_session_or_401(token: str) -> dict:
full = None
if token:
full = get_session(token)
if full:
return full
enroll = get_limited_session(token, "totp_enroll") if token else None
if not enroll:
raise HTTPException(401, "Nicht eingeloggt")
return enroll
@router.get("/totp/status")
def totp_status(x_auth_token: Optional[str] = Header(default=None)):
session = _setup_session_or_401(x_auth_token or "")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT email, role, totp_enabled FROM profiles WHERE id=%s",
(session["profile_id"],),
)
prof = cur.fetchone()
if not prof:
raise HTTPException(401, "Nicht eingeloggt")
role = (prof.get("role") or session.get("role") or "").strip().lower()
enrollment = (session.get("purpose") or "full") == "totp_enroll"
return {
"totp_enabled": bool(prof.get("totp_enabled")),
"enrollment_required": enrollment or (
role in _ADMIN_ROLES and _admin_totp_required() and not prof.get("totp_enabled")
),
"email": prof.get("email"),
"role": role,
}
@router.post("/totp/setup")
def totp_setup(x_auth_token: Optional[str] = Header(default=None)):
"""Neues Geheimnis erzeugen. Wirksam erst nach /totp/confirm."""
session = _setup_session_or_401(x_auth_token or "")
secret = generate_totp_secret()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT email FROM profiles WHERE id=%s", (session["profile_id"],))
prof = cur.fetchone()
if not prof:
raise HTTPException(404, "Profil nicht gefunden")
cur.execute(
"UPDATE profiles SET totp_pending_secret=%s WHERE id=%s",
(secret, session["profile_id"]),
)
email = prof.get("email") or "konto"
return {
"secret": secret,
"otpauth_uri": otpauth_uri(secret=secret, account_name=email),
}
@router.post("/totp/confirm")
def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(default=None)):
session = _setup_session_or_401(x_auth_token or "")
pid = int(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof.get("totp_pending_secret"):
raise HTTPException(400, "Zuerst ein neues Geheimnis erzeugen")
if not verify_totp(prof.get("totp_pending_secret"), req.code):
raise HTTPException(400, "Code stimmt nicht. Uhrzeit des Geräts prüfen.")
cur.execute(
"""
UPDATE profiles
SET totp_secret=%s, totp_pending_secret=NULL, totp_enabled=TRUE
WHERE id=%s
""",
(prof["totp_pending_secret"], pid),
)
plain_codes = _replace_recovery_codes(cur, pid)
prof = dict(prof)
prof["totp_enabled"] = True
prof["totp_secret"] = None
revoke_profile_sessions(cur, pid)
token, expires = _issue_session(cur, pid, days=session_lifetime_days(prof))
payload = _login_payload(prof, token, expires)
payload["recovery_codes"] = plain_codes
return payload
def _replace_recovery_codes(cur, profile_id: int) -> list[str]:
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (profile_id,))
plain_codes = generate_recovery_codes()
for code in plain_codes:
cur.execute(
"""
INSERT INTO profile_recovery_codes (profile_id, code_hash)
VALUES (%s, %s)
""",
(profile_id, hash_recovery_code(code)),
)
return plain_codes
@router.post("/totp/recovery-codes")
def regenerate_recovery_codes(req: TotpDisableRequest, session: dict = Depends(require_auth)):
"""Neue Codes ausgeben. Die bisherigen werden ungültig. Passwort und aktueller App-Code sind Pflicht."""
pid = int(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof.get("totp_enabled"):
raise HTTPException(400, "Zwei-Faktor ist nicht aktiv")
if not verify_pin(req.current_password, prof["pin_hash"]):
raise HTTPException(400, "Aktuelles Passwort ist falsch")
if not verify_totp(prof.get("totp_secret"), req.code):
raise HTTPException(400, "Code aus der App stimmt nicht")
plain_codes = _replace_recovery_codes(cur, pid)
return {"recovery_codes": plain_codes}
@router.post("/totp/disable")
def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)):
pid = int(session["profile_id"])
role = (session.get("role") or "").strip().lower()
if role in _ADMIN_ROLES and _admin_totp_required():
raise HTTPException(400, "Für Plattform-Admins bleibt die Zwei-Faktor-Anmeldung aktiv.")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not verify_pin(req.current_password, prof["pin_hash"]):
raise HTTPException(400, "Aktuelles Passwort ist falsch")
if not (
verify_totp(prof.get("totp_secret"), req.code)
or _consume_recovery_code(cur, pid, req.code)
):
raise HTTPException(400, "Code stimmt nicht")
cur.execute(
"""
UPDATE profiles
SET totp_enabled=FALSE, totp_secret=NULL, totp_pending_secret=NULL
WHERE id=%s
""",
(pid,),
)
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (pid,))
return {"ok": True} return {"ok": True}
@router.post("/media-grant")
def media_grant(session: dict = Depends(require_auth)):
"""Kurzlebiges Token nur für Medien-Dateien. Ersetzt das Sitzungstoken in Bild-URLs."""
token, exp = issue_media_grant(int(session["profile_id"]), ttl_seconds=MEDIA_GRANT_TTL_SECONDS)
return {
"token": token,
"expires_at": datetime.fromtimestamp(exp, tz=timezone.utc).isoformat(),
}
@router.post("/forgot-password") @router.post("/forgot-password")
@limiter.limit("3/minute") @limiter.limit("3/minute")
async def password_reset_request(req: PasswordResetRequest, request: Request): async def password_reset_request(req: PasswordResetRequest, request: Request):
@ -510,17 +146,10 @@ def password_reset_confirm(req: PasswordResetConfirm):
if not sess: if not sess:
raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link") raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
pid = sess["profile_id"] pid = sess['profile_id']
new_hash = hash_pin(req.new_password) new_hash = hash_pin(req.new_password)
cur.execute( cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
""" cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
UPDATE profiles
SET pin_hash=%s, failed_login_count=0, locked_until=NULL
WHERE id=%s
""",
(new_hash, pid),
)
revoke_profile_sessions(cur, int(pid))
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}

View File

@ -324,7 +324,6 @@ def _resolve_optional_auth(cur, x_auth_token: Optional[str]) -> tuple[Optional[i
INNER JOIN profiles p ON p.id = s.profile_id INNER JOIN profiles p ON p.id = s.profile_id
WHERE s.token = %s WHERE s.token = %s
AND (s.expires_at IS NULL OR s.expires_at > NOW()) AND (s.expires_at IS NULL OR s.expires_at > NOW())
AND COALESCE(s.purpose, 'full') = 'full'
""", """,
(x_auth_token,), (x_auth_token,),
) )

View File

@ -3224,7 +3224,7 @@ def download_exercise_media_file(
tenant: TenantContext = Depends(get_tenant_context_flexible), tenant: TenantContext = Depends(get_tenant_context_flexible),
): ):
""" """
Dateiauslieferung mit Governance wie GET Übung — Header-Sitzung oder kurzlebiges Medien-Token (?ssetoken). Dateiauslieferung mit Governance wie GET Übung — Auth via X-Auth-Token oder ?ssetoken (für <img>/<video>).
Unterstützt HTTP Range (206) für MP4-Streaming und Content-Disposition: inline (kein attachment). Unterstützt HTTP Range (206) für MP4-Streaming und Content-Disposition: inline (kein attachment).
""" """
with get_db() as conn: with get_db() as conn:

View File

@ -1225,7 +1225,7 @@ def download_media_asset_file(
asset_id: int, asset_id: int,
tenant: TenantContext = Depends(get_tenant_context_flexible), tenant: TenantContext = Depends(get_tenant_context_flexible),
): ):
"""Direktzugriff auf Archiv-Datei. Auth per Header oder kurzlebigem Medien-Token (?ssetoken).""" """Direktzugriff auf Archiv-Datei (Thumbnail/Vorschau); Auth wie Übungs-Medien (?ssetoken)."""
from routers.exercises import _binary_media_response from routers.exercises import _binary_media_response
with get_db() as conn: with get_db() as conn:

View File

@ -14,7 +14,7 @@ from psycopg2.extras import Json
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, hash_pin, revoke_profile_sessions from auth import require_auth, hash_pin
from club_tenancy import ( from club_tenancy import (
assert_club_member, assert_club_member,
club_ids_for_profile_with_roles, club_ids_for_profile_with_roles,
@ -30,19 +30,6 @@ router = APIRouter(prefix="/api", tags=["profiles"])
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"}) _ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
_CLUB_BLOCKED_PORTAL_TARGETS = frozenset({"admin", "superadmin"}) _CLUB_BLOCKED_PORTAL_TARGETS = frozenset({"admin", "superadmin"})
_PROFILE_SECRET_FIELDS = (
"pin_hash",
"totp_secret",
"totp_pending_secret",
"verification_token",
)
def _public_profile_row(row) -> dict:
data = r2d(row)
for key in _PROFILE_SECRET_FIELDS:
data.pop(key, None)
return data
class ManagementPasswordResetBody(BaseModel): class ManagementPasswordResetBody(BaseModel):
@ -116,7 +103,8 @@ def get_current_profile(
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Profil nicht gefunden") raise HTTPException(404, "Profil nicht gefunden")
data = _public_profile_row(row) data = r2d(row)
data.pop("pin_hash", None)
clubs = memberships_with_roles(cur, profile_id, active_only=False) clubs = memberships_with_roles(cur, profile_id, active_only=False)
data["clubs"] = clubs data["clubs"] = clubs
ac_raw = data.get("active_club_id") ac_raw = data.get("active_club_id")
@ -152,7 +140,9 @@ def list_profiles(session=Depends(require_auth)):
rows = cur.fetchall() rows = cur.fetchall()
out = [] out = []
for r in rows: for r in rows:
out.append(_public_profile_row(r)) d = r2d(r)
d.pop("pin_hash", None)
out.append(d)
return out return out
@ -213,7 +203,9 @@ def profile_document(pid: str) -> dict:
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Profil nicht gefunden") raise HTTPException(404, "Profil nicht gefunden")
return _public_profile_row(row) d = r2d(row)
d.pop("pin_hash", None)
return d
@router.post("/profiles/{pid}/management-password-reset") @router.post("/profiles/{pid}/management-password-reset")
@ -253,14 +245,9 @@ def management_password_reset(
_assert_can_management_password_help(cur, tenant, target, via_email=False) _assert_can_management_password_help(cur, tenant, target, via_email=False)
new_hash = hash_pin(body.new_password) new_hash = hash_pin(body.new_password)
cur.execute( cur.execute(
""" "UPDATE profiles SET pin_hash = %s, updated_at = NOW() WHERE id = %s",
UPDATE profiles
SET pin_hash = %s, failed_login_count = 0, locked_until = NULL, updated_at = NOW()
WHERE id = %s
""",
(new_hash, target), (new_hash, target),
) )
revoke_profile_sessions(cur, target)
return {"ok": True, "mode": "direct"} return {"ok": True, "mode": "direct"}
_assert_can_management_password_help(cur, tenant, target, via_email=True) _assert_can_management_password_help(cur, tenant, target, via_email=True)

View File

@ -1,51 +0,0 @@
"""Login-Sperre, TOTP und Medien-Grant ohne Datenbank."""
from __future__ import annotations
import os
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
os.environ.setdefault("REQUIRE_ADMIN_TOTP", "0")
import pytest
from fastapi.testclient import TestClient
from main import app
from media_grant import issue_media_grant, profile_id_from_media_grant
from totp_auth import generate_totp_secret, totp_code, verify_totp
import time
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_totp_accepts_current_code() -> None:
secret = generate_totp_secret()
code = totp_code(secret, int(time.time()) // 30)
assert verify_totp(secret, code)
assert not verify_totp(secret, "000000") or code == "000000"
def test_media_grant_rejects_session_shaped_token() -> None:
assert profile_id_from_media_grant("not-a-media-grant") is None
token, _exp = issue_media_grant(42, ttl_seconds=60)
assert profile_id_from_media_grant(token) == 42
assert profile_id_from_media_grant(token + "x") is None
def test_change_password_requires_current_password() -> None:
from pydantic import ValidationError
from models import ChangePasswordRequest
with pytest.raises(ValidationError):
ChangePasswordRequest.model_validate({"pin": "neuespasswort"})
def test_login_rejects_oversized_password(client: TestClient) -> None:
response = client.post(
"/api/auth/login",
json={"email": "trainer@example.com", "password": "x" * 200},
)
assert response.status_code == 422

View File

@ -83,20 +83,6 @@ def test_strip_off_topic_steps_from_path():
assert [s["exercise_id"] for s in out] == [1, 2, 4] assert [s["exercise_id"] for s in out] == [1, 2, 4]
def test_build_gap_fill_goal_text_includes_catalog_exercise_hints():
from planning_exercise_semantics import build_semantic_brief
brief = build_semantic_brief("Mae Geri")
text = build_gap_fill_goal_text(
goal_query="Mae Geri lernen",
brief=brief,
spec={"source": "roadmap_unfilled", "phase": "grundlage", "title_hint": "Stand"},
catalog_exercise_hints="Primärfokus: Kihon und Partnerübungen mit Technikbezug.",
)
assert "Katalog-Hinweise Übungsanlage" in text
assert "Kihon" in text
def test_build_gap_fill_goal_text_includes_topic(): def test_build_gap_fill_goal_text_includes_topic():
brief = build_semantic_brief("Mae Geri Perfektion") brief = build_semantic_brief("Mae Geri Perfektion")
text = build_gap_fill_goal_text( text = build_gap_fill_goal_text(

View File

@ -336,29 +336,3 @@ def test_filter_rematch_skips_preserved_slots():
off_topic_steps=[], off_topic_steps=[],
) )
assert filtered == {1} assert filtered == {1}
def test_apply_rematch_catalog_guard_skips_unfilled_only():
from planning_path_rematch import apply_rematch_catalog_guard
indices = {0, 1, 2}
reasons = {
0: "QS-Tier-1",
1: "Keine passende Übung für Roadmap-Stufe",
2: "Passt nicht zur Haupttechnik",
}
filtered = apply_rematch_catalog_guard(
indices,
reasons,
rematch_guard="Keine leeren Slots erzwingen.",
)
assert filtered == {0, 2}
def test_apply_rematch_catalog_guard_inactive_without_guard():
from planning_path_rematch import apply_rematch_catalog_guard
indices = {1}
reasons = {1: "Keine passende Übung für Roadmap-Stufe"}
assert apply_rematch_catalog_guard(indices, reasons, rematch_guard=None) == {1}
assert apply_rematch_catalog_guard(indices, reasons, rematch_guard="") == {1}

View File

@ -1,63 +0,0 @@
"""TOTP (RFC 6238) und einmalige Wiederherstellungscodes — nur Standardbibliothek."""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import struct
import time
from typing import Optional
from urllib.parse import quote
def generate_totp_secret() -> str:
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
def _decode_secret(secret: str) -> bytes:
raw = "".join(ch for ch in (secret or "").upper() if ch.isalnum())
pad = "=" * ((8 - len(raw) % 8) % 8)
return base64.b32decode(raw + pad, casefold=True)
def totp_code(secret: str, counter: int) -> str:
digest = hmac.new(_decode_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
return f"{binary % 1_000_000:06d}"
def verify_totp(secret: Optional[str], code: str, *, window: int = 1, now: Optional[int] = None) -> bool:
if not secret:
return False
digits = "".join(ch for ch in (code or "") if ch.isdigit())
if len(digits) != 6:
return False
moment = int(time.time() if now is None else now)
counter = moment // 30
for delta in range(-window, window + 1):
if hmac.compare_digest(totp_code(secret, counter + delta), digits):
return True
return False
def otpauth_uri(*, secret: str, account_name: str, issuer: str = "Shinkan Jinkendo") -> str:
label = quote(f"{issuer}:{account_name}")
return (
f"otpauth://totp/{label}?secret={quote(secret)}"
f"&issuer={quote(issuer)}&algorithm=SHA1&digits=6&period=30"
)
def generate_recovery_codes(count: int = 8) -> list[str]:
codes = []
for _ in range(count):
raw = secrets.token_hex(4)
codes.append(f"{raw[:4]}-{raw[4:]}")
return codes
def hash_recovery_code(code: str) -> str:
normalized = "".join(ch for ch in (code or "").lower() if ch.isalnum())
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.241" APP_VERSION = "0.8.237"
BUILD_DATE = "2026-10-13" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20261012096" DB_SCHEMA_VERSION = "20260607094"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.3.2", # TOTP-Einrichtung zeigt einen QR-Code zum Scannen "auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte "capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
@ -53,45 +53,11 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.241",
"date": "2026-10-13",
"changes": [
"Zwei-Faktor-Einrichtung zeigt einen QR-Code. Die Authenticator-App kann das Konto scannen, das Geheimnis bleibt als Ersatz sichtbar.",
],
},
{
"version": "0.8.240",
"date": "2026-10-13",
"changes": [
"Zwei-Faktor ist freiwillig. REQUIRE_ADMIN_TOTP erzwingt es nur noch, wenn die Variable ausdrücklich true ist.",
"Wiederherstellungscodes bleiben sichtbar, bis sie bestätigt sind. Neue Codes lassen sich in den Einstellungen erzeugen.",
],
},
{
"version": "0.8.239",
"date": "2026-10-12",
"changes": [
"Login: 8 Fehlversuche sperren das Konto 15 Minuten, gleiche bcrypt-Laufzeit bei unbekannter E-Mail, Limit 10/Minute an der echten Client-IP hinter dem Proxy.",
"Passwortwechsel verlangt das aktuelle Passwort und beendet alle Sitzungen. Reset-Tokens sind keine Login-Sitzung.",
"Zwei-Faktor (TOTP) für Plattform-Admins Pflicht, für andere Konten in den Einstellungen aktivierbar. Migration 096.",
"Medienabruf nutzt ein 10-Minuten-Token (?ssetoken), nicht mehr das Sitzungstoken. API- und Dev-DB-Ports nur an 127.0.0.1. AUTO_ADMIN_FIRST_USER standardmäßig aus.",
],
},
{
"version": "0.8.238",
"date": "2026-05-22",
"changes": [
"Planungs-KI H1.5: rematch_guard im Auto-Rematch (kein Rematch nur wegen leerer Roadmap-Stufe).",
"hints_on_exercise in Gap-Fill, Übungs-KI-Kontext und intent_context_json (Roadmap-Pipeline).",
"Migration 095: keine Migrations-Seeds in catalog_prompt_slots (Prod-sichere Katalogstruktur).",
],
},
{ {
"version": "0.8.237", "version": "0.8.237",
"date": "2026-05-22", "date": "2026-05-22",
"changes": [ "changes": [
"Migration 094/095: Katalog-Prompt-Slots ohne Migrations-Seeds — Inhalte Admin oder Laufzeit-Fallback.", "Migration 094: catalog_prompt_slots vollständig befüllt (Karate, SV, alle Trainingsstile/Zielgruppen).",
"catalog_slot_fallbacks: Namens-Fallback bis Admin-Override — gleiche Qualität wie H1-Registry.", "catalog_slot_fallbacks: Namens-Fallback bis Admin-Override — gleiche Qualität wie H1-Registry.",
], ],
}, },

View File

@ -12,7 +12,7 @@ services:
volumes: volumes:
- dev-shinkan-db-data:/var/lib/postgresql/data - dev-shinkan-db-data:/var/lib/postgresql/data
ports: ports:
- "127.0.0.1:5435:5432" - "5435:5432"
restart: unless-stopped restart: unless-stopped
networks: networks:
- dev-shinkan-network - dev-shinkan-network
@ -38,8 +38,6 @@ services:
APP_URL: "${APP_URL:-https://dev.shinkan.jinkendo.de}" APP_URL: "${APP_URL:-https://dev.shinkan.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}" ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
ENVIRONMENT: "${ENVIRONMENT:-development}" ENVIRONMENT: "${ENVIRONMENT:-development}"
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}"
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen) # M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}" CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}" MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
@ -53,7 +51,7 @@ services:
volumes: volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media/dev}:${MEDIA_ROOT:-/app/media} - ${SHINKAN_MEDIA_HOST:-/shinkan-media/dev}:${MEDIA_ROOT:-/app/media}
ports: ports:
- "127.0.0.1:8098:8000" - "8098:8000"
depends_on: depends_on:
- postgres - postgres
restart: unless-stopped restart: unless-stopped

View File

@ -38,8 +38,7 @@ services:
SMTP_SSL: ${SMTP_SSL:-} SMTP_SSL: ${SMTP_SSL:-}
SMTP_STARTTLS: ${SMTP_STARTTLS:-} SMTP_STARTTLS: ${SMTP_STARTTLS:-}
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@… # Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}" AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-true}"
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}"
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}" ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
# Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML # Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}" APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
@ -62,7 +61,7 @@ services:
volumes: volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media} - ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
ports: ports:
- "127.0.0.1:8003:8000" - "8003:8000"
depends_on: depends_on:
- postgres - postgres
restart: unless-stopped restart: unless-stopped

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover # Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-22 (Katalog-Prompt-Slots H2/H1.5, **0.8.238**) **Stand:** 2026-05-22 (F15 Graph-Match & getrennte Pfad-QS, lokal nach **0.8.233**)
**App-Version / DB-Schema:** App **`0.8.238`**; DB Migration **095** (keine Slot-Seeds; Prod-sichere Katalogstruktur). **App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); **F15** siehe §2.8 — DB unverändert (`DB_SCHEMA_VERSION`, Migration **088**).
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**. 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**.
@ -115,8 +115,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** | | **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** |
| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** | | **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** |
| **F15** | Unified Slot-Review (Match-Dialog), getrennte Pfad-QS, `findings_stale` | ✅ lokal (nach 0.8.233) | | **F15** | Unified Slot-Review (Match-Dialog), getrennte Pfad-QS, `findings_stale` | ✅ lokal (nach 0.8.233) |
| **H1–H2.1** | Katalog-Prompt-Slots (DB, Resolver, Admin-UI, granulare Prompts) | ✅ **0.8.234–238** — Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** | | **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
| **H1.5** | `rematch_guard`, `hints_on_exercise` in Rematch/Gap-Fill/Intent | ✅ **0.8.238** |
**Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16. **Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16.
@ -149,7 +148,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
5. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots 5. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots
6. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16) 6. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
7. Technik-Katalog konfigurierbar (Backlog) 7. Technik-Katalog konfigurierbar (Backlog)
8. **H1–H2.1** — Katalog-Prompt-Slots ✅ · **Prompt-Editor-Überarbeitung** → eigene Session 8. **H1** — Katalog-Prompt-Snippets (modulare LLM-Anweisungen)
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**) #### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)

View File

@ -1,7 +1,7 @@
# Planungs-KI — Katalog-Prompt-Slots (Snippets) # Planungs-KI — Katalog-Prompt-Slots (Snippets)
**Stand:** 2026-05-22 **Stand:** 2026-05-22
**Status:** **H2.1** umgesetzt (0.8.236) · **H1.5** Rematch-Guard + Gap-Fill-Hints (0.8.238) · **Prompt-Editor-Überarbeitung** bewusst ausgelagert **Status:** **H2** umgesetzt (0.8.235) · **H2.1** Admin-UI + granulare Prompts (0.8.236)
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py` · `catalog_prompt_slots.py` **Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py` · `catalog_prompt_slots.py`
--- ---
@ -173,12 +173,12 @@ Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_bloc
- [x] Slot-Editor an Fokusbereich / Trainingsstil / Zielgruppe / Stilrichtung (`CatalogPromptSlotsEditor`, Stammdaten-Katalog) - [x] Slot-Editor an Fokusbereich / Trainingsstil / Zielgruppe / Stilrichtung (`CatalogPromptSlotsEditor`, Stammdaten-Katalog)
- [x] Prompt-Templates mit granularen Platzhaltern (Migration 093) - [x] Prompt-Templates mit granularen Platzhaltern (Migration 093)
- [ ] **Prompt-Editor komplett überarbeiten** (Platzhalter-Hilfe, UX) — **eigene Session** - [ ] Platzhalter-Hilfe im KI-Prompt-Editor (erweitert)
### H1.5 ✓ (0.8.238) ### H1.5
- [x] `rematch_guard` im Rematch-Loop (`apply_rematch_catalog_guard`) - [ ] `rematch_guard` im Rematch-Loop
- [x] Gap-Fill + Intent: `hints_on_exercise` via `catalog_exercise_hints_block` / `intent_context_json` - [ ] Intent-Prompts + Gap-Fill: `hints_on_exercise`
### H3 — Trainingsplanung (Phase G) ### H3 — Trainingsplanung (Phase G)
@ -211,7 +211,6 @@ Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_bloc
| Datum | Änderung | | Datum | Änderung |
|-------|----------| |-------|----------|
| 2026-05-22 | **H1.5** (0.8.238): rematch_guard; hints_on_exercise in Gap-Fill/Intent; Migration 095 ohne Seeds |
| 2026-05-22 | **H2.1** (0.8.236): Admin-UI `CatalogPromptSlotsEditor`; Migration 093 granulare Prompt-Templates | | 2026-05-22 | **H2.1** (0.8.236): Admin-UI `CatalogPromptSlotsEditor`; Migration 093 granulare Prompt-Templates |
| 2026-05-22 | **H2** (0.8.235): Slot-Typ-Register + `catalog_prompt_slots` DB, granulare Platzhalter, Admin-API | | 2026-05-22 | **H2** (0.8.235): Slot-Typ-Register + `catalog_prompt_slots` DB, granulare Platzhalter, Admin-API |
| 2026-05-22 | Konzept §4–§8: zwei Ebenen Slot-Typ vs. Slot-Wert; Platzhalter `{kind}_{slot_key}` | | 2026-05-22 | Konzept §4–§8: zwei Ebenen Slot-Typ vs. Slot-Wert; Platzhalter `{kind}_{slot_key}` |

View File

@ -1,358 +0,0 @@
# Designprinzipien – Abgleich Mitai ↔ Shinkan
**Status:** Review / Entscheidungsgrundlage
**Stand:** 2026-07-04
**Zweck:** Widersprüche, bewusste Abweichungen und Implementierungslücken zwischen den Designprinzipien-Serien identifizieren — Basis für **Familien-Entscheidungen** und langfristige Konvergenz von Mitai und Shinkan.
**Quellen:**
| App | Index |
|-----|--------|
| Mitai (Foundation) | [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md) — 9 Module |
| Shinkan | [design-principles/DESIGN_PRINCIPLES_INDEX.md](./design-principles/DESIGN_PRINCIPLES_INDEX.md) — 15 Module |
---
## 1. Kurzfassung
| Kategorie | Anzahl | Bedeutung |
|-----------|--------|-----------|
| **Familien-Konsens** | 12 Muster | In beiden Serien gleich oder kompatibel — **verbindlich für neue Apps** |
| **Bewusste Produkt-Abweichung** | 8 | Fachlich/Architektur begründet — **nicht angleichen**, aber im Familienmodell verankern |
| **Konzeptuelle Spannung** | 6 | Widersprüche oder gegenläufige Defaults — **Familien-Entscheidung nötig** |
| **Ist vs. Prinzip (Schuld)** | 14+ | Mindestens eine App verletzt eigene oder Schwester-Prinzipien — **Remediation** |
| **Nur Shinkan** | 6 Module | Mandanten-/Domänen-Bausteine ohne Mitai-Pendant |
| **Nur Mitai (reifer)** | 3 Muster | Data Layer, Widget-Dashboard, Universal Import — Shinkan vereinfacht oder fehlt |
**Kernbefund:** Mitai und Shinkan teilen dieselbe **technische Basis** (Auth, Migration, Nav-SSoT, Registry-Denken, Probe→Enforce), divergieren aber strukturell bei **Entitlement-Subjekt** (Profil vs. Verein), **Berechnungsarchitektur** (generischer Data Layer vs. domänenspezifisches Scoring) und **KI-Reife** (Unified Executor vs. schmale Laufzeit).
---
## 2. Familien-Konsens (für neue Produkte übernehmen)
Diese Muster sind in beiden Serien explizit oder implizit tragfähig:
| # | Muster | Mitai | Shinkan |
|---|--------|-------|---------|
| F1 | Server-Sessions + `Depends(require_auth)` | Auth #1–3 | Auth #1–2 |
| F2 | `profile_id` aus Session, nie aus Client-Header | Auth #3 | Auth #3 |
| F3 | Auth getrennt von Authorization/Entitlements | Auth #10 | Capabilities + Access Layer |
| F4 | Nummerierte SQL-Migrationen + Tracking beim Container-Start | Migration #1–5 | Migration #1–3 |
| F5 | Fail-fast, kein Auto-Rollback | Migration #5 | Migration #2 |
| F6 | `appNav` / zentrale Nav-Config als SSoT | Navigation #1 | Navigation #1 |
| F7 | Admin als eigener Hub/Realm | Navigation #6–7 | Navigation #2 |
| F8 | DB-konfigurierbare KI-Prompts (nicht hardcoded Prod) | Prompt #2 | AI Runtime #2 |
| F9 | Template vs. Kontext/Daten trennen | Prompt #5 | AI Runtime #4 |
| F10 | Ingest ≠ Interpretation beim Import | Import #2 | Wiki Import #1 |
| F11 | Preview/Dry-Run vor Massenimport | Import #3+ | Wiki Import #2 |
| F12 | 4-Phasen-Rollout Entitlements (Log → Enforce) | Feature #8 | Capability #2 |
**Empfehlung:** Als **`JINKENDO_FOUNDATION_CHECKLIST`** in künftigen Apps verpflichtend; Details pro Modul in den Einzeldokumenten.
---
## 3. Modul-Abgleich (9 vergleichbare Paare)
Legende **Bewertung:**
| Symbol | Bedeutung |
|--------|-----------|
| ✅ | Prinzipien aligned / kompatibel |
| ⚠️ | Teilweise aligned; Lücken in Implementierung oder Doku |
| 🔀 | Bewusste Produkt-Divergenz (kein Bug) |
| ❌ | Widerspruch oder gegenläufiges Konzept — Entscheidung nötig |
| 🏗️ | Ist-Stand verletzt dokumentierte Prinzipien (Architekturschuld) |
---
### 3.1 Prompt Engine (Mitai #1) ↔ AI Prompt Runtime (Shinkan #5)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| Single Entry Point | `execute_prompt` / Unified System | `ai_prompt_runtime` + verteilte Orchestratoren | ❌ Konzept |
| Prompt-Typen | base / pipeline / workflow | nur slug + Mustache | 🔀 Shinkan bewusst schlanker |
| Platzhalter-Registry | zentral, API-Verträge | Kontext-Arten (`AiPromptContextKind`), kein Registry-Katalog | ⚠️ |
| Data Layer-Anbindung | Layer 1 → Resolver | Domänen-Builder ad hoc | ⚠️ |
| Debug/Preview | ausgereift | Admin-Vorschau, weniger Runtime-Transparenz | ⚠️ |
| Feature-Gating an Execute | teils fehlend (Legacy) | Capability geplant, teils Probe | 🏗️ beide |
**Widersprüche / gegenläufig:**
- Mitai: **Ein Executor** ist Kernprinzip. Shinkan: **kein** vergleichbarer Executor — Planungs-KI umgeht teils die Laufzeit.
- Beide warnen vor **parallelen KI-Pfaden**; beide haben sie noch (Mitai `insights.py`, Shinkan Router-OpenRouter).
**Familien-Entscheidung (Vorschlag):**
| Option | Inhalt |
|--------|--------|
| **Zielbild** | Gemeinsame **`prompt_executor`-Fassade** (Package oder Copy mit Namespace); Shinkan-Kontext-Builder als Plugins |
| **Shinkan-Roadmap** | Planungs-Orchestrierung in Laufzeit ziehen; keine Workflow-Graphs vor Planungs-Kontext-Reife |
| **Nicht kopieren** | Mitai: doppelte Pipeline-Modelle, PLACEHOLDER_MAP-Duplikat, Roh-SQL im Executor |
---
### 3.2 Data Layer (Mitai #2) ↔ Skill Scoring (Shinkan #8)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| Berechnungs-SSoT | `data_layer/` Layer 0→1→2 | nur `skill_scoring.py` | ❌ Abdeckung |
| Router delegieren | explizites Prinzip #13 | Skill-Router ja; Planung teils nicht | ⚠️ |
| Confidence / data_points | Pflicht-Metadaten | nicht analog | 🔀 Domäne anders |
| Chart/KPI-Anbindung | Layer 2b Adapter | KPI-Dashboard ruft Router-Helfer | ⚠️ |
| Import-Grenze | keine Scores beim Insert | Wiki: explizit kein Scoring beim Insert | ✅ |
**Widerspruch:**
- Mitai postuliert **generische Berechnungsschicht** für die ganze App. Shinkan hat **kein** Data Layer — nur ein **domänenspezifisches** Scoring-Modul. Das ist keine Implementierungslücke allein, sondern **unterschiedliche Architektur-Tiefe**.
**Familien-Entscheidung (Vorschlag):**
| Option | Inhalt |
|--------|--------|
| **Familien-Prinzip** | „Berechnungen in benannter Schicht, nicht in Router/React“ — **Ja** |
| **Implementierung** | Mitai: `data_layer/` bleibt Referenz. Shinkan: Skill Scoring **ist** Layer-1-Vorbild; langfristig **`planning_metrics/`** o. ä. statt Router-SQL |
| **Nicht verallgemeinern** | Mitai-Formeln (TDEE, WHR) — nur Schichtenmodell übernehmen |
---
### 3.3 Feature & Entitlement (Mitai #3) ↔ Capability & Club Features (Shinkan #2)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| Subjekt | **Profil** + Tier | **Verein** (`club_id`) + Plan | ❌ Scope |
| Auflösungs-API | `check_feature_access` | `check_capability` + `club_features` + `/me/entitlements` | 🔀 |
| Rollout 4 Phasen | ja | ja (Env-Flags) | ✅ |
| Registry | DB `features` + Tiers | Code-Registry → DB Sync | ⚠️ |
| Capabilities vs. Features | Features only | Capabilities **und** Kontingente getrennt | 🔀 Shinkan feiner |
| Widget/Layout-Gating | zentral | Entitlements-API, kein Widget-Layout | ⚠️ |
**Größter Familien-Konflikt:**
Mitai-Dokument #3 „Nicht übernehmen“ Punkt 10: *„Profile as Entitlement-Subject — Multi-App-Familie braucht separates Identity/Subscription-Boundary.“*
Shinkan **ist** die Antwort mit Verein als Subjekt — aber es gibt **kein gemeinsames Familienmodell**, das Profil-Tier **und** Org-Limits kombiniert.
**Familien-Entscheidung (Vorschlag):**
```
Entitlement-Subjekt (familie):
├── account (profile_id) → Tier, persönliche Limits (Mitai)
└── tenant (club_id?) → Org-Plan, Capabilities (Shinkan, optional null)
API: GET /me/entitlements?tenant_id=
Enforcement: eine resolve_entitlement(subject, capability|feature)
```
| App | Remediation |
|-----|-------------|
| Mitai | Org-Scope reservieren; Tier-Drift (#3 Schuld) bereinigen |
| Shinkan | `CLUB_FEATURE_ENFORCE=1` produktiv; Mitai-Legacy `check_feature_access` nicht nutzen (bereits Regel) |
---
### 3.4 Registry / Plugin (Mitai #4) ↔ Rights Registry (Shinkan #3)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| Registry-first | Platzhalter, Widgets, CSV-Module | Capabilities + Features only | ⚠️ Abdeckung |
| Validierung an Grenze | ja | ja (`register_*` wirft) | ✅ |
| Runtime + DB | Dual (Katalog + DB-Overrides) | Code → DB Upsert | ✅ |
| Dual Registry FE/BE | Widgets + `registerDashboardWidgets` | nicht vorhanden | 🔀 |
| Metadaten-Tiefe | nach Risiko (Matrix in Doc) | schlankere Dataclasses | ✅ |
**Kein Widerspruch** — Shinkan Rights Registry ist **Teilmenge** des Mitai-Meta-Musters.
**Familien-Entscheidung:** Mitai-Registry-Matrix (Platzhalter / UI-Plugin / Import-Modul / Rechte) als **Familien-Taxonomie**; Shinkan erweitert um Import-/Prompt-Registry wenn Wiki-Import generisch wird.
---
### 3.5 Auth & Session (Mitai #5 ↔ Shinkan #4)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| Session-Modell | opaque token | gleich (shared `auth.py`) | ✅ |
| Depends-Pattern | ja | ja (+ TenantContext) | ✅ |
| IDOR Profile-Header | dokumentierte Schwäche | Shinkan: Session-only betont | ⚠️ prüfen |
| RBAC | `role` admin/user | Portal-Rolle **+** Vereinsrollen | 🔀 |
| Rate Limiting | ja | (Mitai-spezifisch in Router) | ⚠️ |
| Feature-Flags in Session | Legacy-Spalten | Account-Lifecycle separat | 🏗️ Mitai |
**Gegenläufig:** Shinkan **erweitert** Auth um Mandanten — darf Mandantenlogik **nicht** in `auth.py` legen (Shinkan-Prinzip „Nicht übernehmen“).
**Familien-Entscheidung:** Gemeinsames Auth-Modul; **TenantContext** als optionales Add-on-Pattern für mandantenfähige Apps.
---
### 3.6 Universal Import (Mitai #6) ↔ Wiki Import (Shinkan #11)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| Ingest ≠ Interpretation | ✅ | ✅ | ✅ |
| Modul-Registry | zentral | fehlt (wiki-hardcoded) | ⚠️ |
| SAVEPOINT pro Zeile | ja | nicht dokumentiert | ⚠️ |
| Vorlagen/Mappings | generisch | SMW-Kategorien via Env | 🔀 |
| Feature-Limits | an Import gebunden | Admin-only | ⚠️ |
**Kein Konflikt** — unterschiedliche Reife. Shinkan ist **Spezialfall** des Mitai-Musters.
**Familien-Entscheidung:** Neue Import-Quellen über **Universal-Import-Gerüst** (Mitai); Wiki als `import_type=mediawiki` registrieren.
---
### 3.7 Dashboard Widgets (Mitai #7) ↔ Dashboard KPIs (Shinkan #14)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| UX-Modell | konfigurierbares Widget-Layout | festes KPI-Aggregat | ❌ UX-Konzept |
| Chatty Client vermeiden | via Widget-Daten | via `/dashboard/kpis` | ✅ Ziel |
| Entitlements | `allowed` pro Widget | TenantContext auf KPIs | ✅ |
| Data Layer | Widgets konsumieren Layer 1 | intern Router-Helfer | ⚠️ |
**Gegenläufig:** Mitai: **Nutzer konfiguriert Dashboard**. Shinkan: **Produkt definiert feste Kacheln** — bewusste MVP-Vereinfachung.
**Familien-Entscheidung:**
| App-Typ | Dashboard-Pattern |
|---------|-------------------|
| Personal Tracking (Mitai) | Widget-Katalog + Layout-JSON |
| Trainer/Verein (Shinkan) | Aggregierte KPI-Endpoints ausreichend; Widget-System optional Phase 2 |
| Neue App | Aggregat-Endpoint **mindestens**; Widget-System wenn Personalisierung nötig |
---
### 3.8 Navigation / IA (Mitai #8 ↔ Shinkan #12)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| appNav SSoT | ja | ja | ✅ |
| Admin-Hub | Shell + Hub-Gruppen | horizontale `AdminPageNav` | ⚠️ |
| Breakpoint 1024px | explizit | „prüfen“ | ⚠️ |
| Onboarding-Nav | — | reduziert ohne Verein | 🔀 Shinkan |
| adminNav.js SSoT | empfohlen | hardcoded Array in JSX | 🏗️ Shinkan |
| Safe Area PWA | ja | Design-System, weniger explizit | ⚠️ |
**Familien-Entscheidung:** `appNav.js` + **`adminNav.js`** als Pflicht; Shinkan `AdminPageNav` refactoren.
---
### 3.9 Migration & Deploy (Mitai #9 ↔ Shinkan #13)
| Aspekt | Mitai | Shinkan | Bewertung |
|--------|-------|---------|-----------|
| XXX_*.sql + schema_migrations | ✅ | ✅ | ✅ |
| Startup vor App | ✅ | ✅ | ✅ |
| develop/main | ✅ | ✅ | ✅ |
| Feste Ports | ✅ | ✅ | ✅ |
| Immutabler Docker-Build | dokumentiert | nicht im Shinkan-Doc | ⚠️ Doku |
| Health-Check / PG wait | ausführlich | kürzer | ⚠️ Doku |
**Aligned** — Shinkan-Dokument ist **Untermenge**; Implementierung vermutlich gleich (shared Infra).
---
## 4. Nur Shinkan (6 Module) — Einordnung für die Familie
| Modul | Familien-Relevanz | Mitai-Bezug |
|-------|-------------------|------------|
| **Access Layer & Tenant** | **Pflicht** für mandantenfähige Apps | Mitai #3 fordert Org-Boundary — hier ausformuliert |
| **Media Assets & Archiv** | Optional (Content-Apps) | — |
| **Exercise Catalog** | Shinkan-Domäne | — |
| **Training Planning** | Shinkan-Domäne | — |
| **Content Reports (P-13)** | Empfohlen für UGC/Plattform | — |
| **Maturity Models** | Optional (Kompetenz-Apps) | — |
**Kein Widerspruch zu Mitai** — ergänzen das Familienmodell um **Mandant + Content-Governance**.
---
## 5. Querschnitt: Ist-Stand vs. dokumentierte Prinzipien
Gemeinsame **Architekturschuld** (beide Apps verletzen teils eigene „Nicht übernehmen“-Listen):
| Thema | Mitai | Shinkan |
|-------|-------|---------|
| Parallele KI-Pfade | `insights.py` Legacy | OpenRouter direkt in Routern |
| Entitlement Enforcement | teils UI-only / Legacy-Spalten | Env Probe-only |
| Registry-Sync / Duplikat | PLACEHOLDER_MAP + Registry | Capabilities-Sync, kein Prompt-Registry |
| Frontend ohne Backend-Gate | teils | Capabilities Probe |
| Dokumentations-Drift | Tier vs. Enforcement-Docs | Endpoint-Audit unvollständig |
| Monolithische Pages/Client | God Pages, api.js | God Pages, api.js (Roadmap Phase 4) |
| Fehlende JSON-Schema-KI | TODO | TODO (explizit vermeiden) |
---
## 6. Entscheidungs-Matrix (Priorisiert)
| Prio | Entscheidung | Betroffene Apps | Empfohlene Familien-Regel |
|------|--------------|-----------------|---------------------------|
| **P0** | Entitlement-Subjekt: Profil **und** optional Tenant | Mitai, Shinkan, neu | Ein API-Shape `/me/entitlements`; zwei Subjekt-Ebenen |
| **P0** | Kein Client-`profile_id` für AuthZ | alle | Session-only; Tenant via Header + Membership |
| **P1** | KI: ein Executor pro App | Mitai (fertig), Shinkan (Ziel) | `execute_prompt(slug, context_dto)` |
| **P1** | Berechnungs-SSoT-Schicht | Shinkan erweitern | Mindestens ein `*/metrics.py` pro Domäne mit KPIs |
| **P1** | Enforcement produktiv | beide | Phase 4 Enforce in Prod für kritische Features |
| **P2** | Registry-Taxonomie vereinheitlichen | beide | Rechte / Platzhalter / Import / UI-Plugin |
| **P2** | Admin-Nav SSoT | Shinkan | `adminNav.js` wie Mitai |
| **P2** | Import: Universal + Spezialmodule | Shinkan | Wiki als registriertes Modul |
| **P3** | Dashboard: Aggregat vs. Widgets | produktabhängig | Entscheidungsbaum §3.7 |
| **P3** | Shared `auth.py` / `db_init` | beide | Monorepo-Package oder Sync-Disziplin |
---
## 7. Konvergenz-Roadmap (langfristig)
```mermaid
flowchart LR
subgraph foundation [Familien-Foundation]
M9[Migration]
M5[Auth]
F3[Entitlements 2-Ebenen]
R4[Registry Meta]
end
subgraph mitai [Mitai]
DL[Data Layer]
PE[Prompt Engine]
DW[Dashboard Widgets]
end
subgraph shinkan [Shinkan]
AL[Access Layer]
AR[AI Runtime → Executor]
SS[Skill Scoring → Layer 1]
end
M9 --> mitai
M9 --> shinkan
M5 --> mitai
M5 --> shinkan
F3 --> mitai
F3 --> shinkan
R4 --> mitai
R4 --> shinkan
AL -.->|Mandanten-Apps| foundation
PE -.->|Konvergenz| AR
DL -.->|Schichtenmodell| SS
```
| Phase | Mitai | Shinkan |
|-------|-------|---------|
| **Kurz** | Legacy KI-Pfade entfernen; Enforcement-Doku vereinheitlichen | Access-Layer-Audit abschließen; `CAPABILITY_ENFORCE` |
| **Mittel** | Org-Scope in Entitlements vorbereiten | `ai_prompt_runtime` → Unified Executor; Router-Helfer statt KPI-Duplikat |
| **Lang** | SSO/Identity (Vision) | Data-Layer-ähnliche Module für Planung; optional Widget-Dashboard |
---
## 8. Nächste Schritte
1. **Review-Workshop:** Tabelle §6 P0–P1 durchgehen und Familien-Regeln verbindlich markieren.
2. ~~**`FAMILY_ENTITLEMENT_MODEL.md`** anlegen (P0)~~ → [FAMILY_ENTITLEMENT_MODEL.md](./FAMILY_ENTITLEMENT_MODEL.md) (Entwurf 2026-07-04)
3. **Shinkan-Index** und **Mitai-Foundation-README** auf dieses Dokument verlinken.
4. **`FAMILY_ENTITLEMENT_MODEL.md`** — Entwurf angelegt (§6 Produkt-Abweichungen, §8 Regeln neue Apps).
5. Pro **P1-Punkt** Issue/Remediation-Eintrag in jeweiliger `SCHULDEN_UND_REMEDIATION` / Mitai-Äquivalent.
---
## 9. Changelog
| Datum | Änderung |
|-------|----------|
| 2026-07-04 | Erstfassung Abgleich Mitai Foundation (9) ↔ Shinkan (15) |

View File

@ -1,316 +0,0 @@
# Familien-Modell: Entitlements & Limits
**Status:** Entwurf / verbindliche Zielrichtung (mit bewussten Produkt-Ausnahmen)
**Stand:** 2026-07-04
**Bezüge:**
- [DESIGN_PRINCIPLES_ALIGNMENT.md](./DESIGN_PRINCIPLES_ALIGNMENT.md) — Abgleich Mitai ↔ Shinkan
- Mitai: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
- Shinkan: [CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md](./design-principles/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md)
---
## 1. Zweck
Dieses Dokument definiert das **gemeinsame Entitlement-Modell** der Jinkendo-Produktfamilie:
- **Was** für neue Apps und Refactors **Standard** ist
- **Welche Produkt-Profile** welche Scopes nutzen (Account, Tenant, beides, keins)
- **Wie** bewusste Abweichungen dokumentiert werden — Abweichung ist erlaubt, **Undokumentiertheit** nicht
**Nicht enthalten:** Stripe/SSO (`auth.jinkendo.de`), vollständige Billing-Implementierung, app-spezifische Feature-IDs.
---
## 2. Leitgedanke: Vier getrennte Fragen
Jede geschützte Aktion durchläuft konzeptionell **vier unabhängige Prüfungen**. Nicht jede App implementiert alle vier — siehe §6.
| # | Frage | Familien-Begriff | Typische Quelle |
|---|--------|------------------|-----------------|
| **A** | Wer ist eingeloggt? | **Auth** (Identität) | Session → `profile_id` |
| **B** | Darf diese Rolle die **Funktion** ausführen? | **Capability** (Permission) | Rollen-Matrix, `min_account_state` |
| **C** | Ist das **Kontingent** erschöpft? | **Feature / Limit** (Quota) | Plan, Tier, Usage-Zähler |
| **D** | Darf ich **dieses Objekt** lesen/ändern? | **Governance** (Object ACL) | `visibility`, `club_id`, Owner |
```
Request → Auth (A) → [Capability (B)] → [Feature-Limit (C)] → [Governance (D)] → Handler
```
**Familien-Regel:** B, C und D **nicht** in React-Widgets oder Router-Inline-Logik vermischen — jeweils eine Auflösungsfunktion pro Ebene.
**Shinkan-Ergänzung:** Governance ist dort ausgebaut (`TenantContext`, Access Layer); Mitai fokussiert A+B+C auf Profil-Ebene.
---
## 3. Familien-Standard: Zwei Subjekt-Ebenen
Limits und Pläne können an **zwei Subjekte** hängen. Beide sind im Familienmodell **first-class** — Apps wählen, welche sie nutzen (§6).
| Subjekt | ID | Typische Frage | Beispiel |
|---------|-----|----------------|----------|
| **Account** | `profile_id` | Was darf **ich** als Nutzer (Tarif)? | Mitai Free vs. Premium |
| **Tenant** | `tenant_id` (z. B. `club_id`) | Was darf **meine Organisation**? | Shinkan Vereinsplan, KI-Kontingent |
### 3.1 Auflösungs-Reihenfolge (wenn beide Ebenen aktiv)
Für eine Aktion mit Capability `X` und Feature `Y`:
1. **Account-Lifecycle** — z. B. E-Mail verifiziert, Onboarding abgeschlossen
2. **Capability(B)** — Rolle darf Funktion (Account- und/oder Tenant-Rollen)
3. **Feature(C)** — Kontingent am **primären Billing-Subjekt** der App (siehe Produkt-Profil)
4. **Governance(D)** — Objekt sichtbar/bearbeitbar
**AND-Verknüpfung:** Alle aktiven Ebenen müssen passieren. Ausnahmen nur in §6.3 dokumentiert.
### 3.2 Primäres Billing-Subjekt pro App
| Profil | Primäres Subjekt für Limits | Capability-Subjekt |
|--------|----------------------------|-------------------|
| Personal App (Mitai) | **Account** | Account |
| Mandanten-App (Shinkan) | **Tenant** | Account + Tenant-Rolle |
| Hybrid (Zukunft) | konfigurierbar | beide |
---
## 4. Familien-Standard: Capabilities vs. Features
| Konzept | Familien-Definition | Subjekt | Beispiel |
|---------|---------------------|---------|----------|
| **Capability** | Binäre oder rollenbasierte **Erlaubnis** („darf ich?“) | meist Account + Tenant-Kontext | `exercises.ai.suggest` |
| **Feature** | **Kontingent** oder Boolean-Limit („wie oft/noch?“) | Account **oder** Tenant | `ai_calls` / Monat |
| **Verknüpfung** | Capability kann `linked_feature_id` haben | — | KI-Capability → KI-Kontingent |
**Familien-Regel:**
- Capabilities **registry-first** registrieren (Code → DB-Sync, Shinkan-Muster).
- Feature-IDs **nicht** in UI hardcoden — nur aus Entitlements-Response.
- `NULL` Limit = unbegrenzt; `0` = deaktiviert (Mitai-Semantik, familienweit).
---
## 5. Familien-Standard: API & Enforcement
### 5.1 Ziel-API (neue Apps)
Ein **einheitlicher Snapshot** für das Frontend:
```
GET /api/me/entitlements
?tenant_id=<optional>
Response (skizziert):
{
"account": {
"profile_id": 1,
"account_state": "active_member",
"tier_id": "premium", // optional, Account-Apps
"features": { "ai_calls": { "allowed", "used", "limit", "remaining", "reset_at" } },
"capabilities": { "analysis.run": { "allowed": true, "reason": null } }
},
"tenant": { // null wenn App keinen Tenant kennt
"tenant_id": 42,
"tenant_type": "club",
"plan_id": "pro",
"features": { ... },
"capabilities": { ... },
"roles": ["trainer"]
},
"enforcement": {
"capabilities": "enforce|probe",
"features": "enforce|probe"
}
}
```
**Familien-Regel:** UI liest **nur** diesen Snapshot (oder domänenspezifische Teilmenge) — keine parallelen `/subscription/me` + `/features/usage` + Ad-hoc-Checks in neuen Apps.
### 5.2 Ist-API (bestehende Apps — Abweichung dokumentiert)
| App | Endpoint heute | Familien-Ziel |
|-----|----------------|---------------|
| **Mitai** | `/subscription/me`, `/features/usage`, `check_feature_access` | Snapshot schrittweise; Account-Block reicht |
| **Shinkan** | `GET /api/me/entitlements?club_id=` | Tenant-Block + Capabilities; Account-Tier fehlt bewusst |
Migration: **kein Big-Bang** — alte Endpoints als Facade auf Snapshot mappen.
### 5.3 Vier-Phasen-Rollout (familienweit verbindlich)
| Phase | Verhalten | Env-Beispiel |
|-------|-----------|--------------|
| **1** | Cleanup Legacy-Flags | — |
| **2** | **Probe** — JSON-Log, HTTP 200 | `*_ENFORCE=0` |
| **3** | Frontend-Gates aus Entitlements | — |
| **4** | **Enforce** — HTTP 403 | `*_ENFORCE=1` |
**Familien-Regel:** Phase 4 für **neue** kritische Features von Anfang an planbar; Bestands-Apps dürfen in Phase 2–3 bleiben bis kalibriert.
### 5.4 Enforcement-Priorität
1. **API** — autoritativ (`403`)
2. **Frontend** — UX (Badges, disabled Buttons)
3. **Niemals** — nur UI ohne API-Gate
---
## 6. Produkt-Profile & bewusste Abweichungen
Abweichungen vom Familien-Standard sind **zulässig**, wenn sie in der Tabelle **§6.2** stehen und begründet sind.
### 6.1 Profil-Matrix (Soll)
| Profil | Apps | Account-Limits | Tenant-Limits | Capabilities | Governance (Objekt) |
|--------|------|----------------|---------------|--------------|---------------------|
| **P1 Personal** | Mitai | ✅ primär | ❌ | ✅ Account | minimal / privat |
| **P2 Mandant** | Shinkan | ⚠️ Lifecycle only | ✅ primär | ✅ Account + Tenant-Rollen | ✅ Access Layer |
| **P3 Minimal** | Miken, Ikigai (geplant) | optional | ❌ | optional | minimal |
| **P4 Hybrid** | (Reserve) | ✅ | ✅ | ✅ beide | ✅ |
### 6.2 Registrierter Abweichungs-Katalog
| ID | App | Abweichung vom Familien-Standard | Begründung | Review |
|----|-----|----------------------------------|------------|--------|
| **DEV-01** | Mitai | Kein `tenant`-Block in Entitlements | Persönliche Tracking-App; kein Verein | Beibehalten (P1) |
| **DEV-02** | Mitai | Kein Capability-Katalog (nur Features+Tier) | RBAC = admin/user ausreichend | Optional später `account.*` Capabilities |
| **DEV-03** | Mitai | Mehrere Nutzer-APIs statt einem Snapshot | Historisch gewachsen | Facade → Snapshot (mittelfristig) |
| **DEV-04** | Mitai | Legacy Session-Spalten (`ai_enabled`, …) parallel Features | Migrationsschuld | Bereinigen, nicht in neue Apps |
| **DEV-05** | Shinkan | Kein Account-Tier / `profiles.tier` | Verein zahlt, nicht Trainer | Beibehalten (P2) |
| **DEV-06** | Shinkan | Capabilities **und** Governance (zwei Achsen) | Trainer vs. Objekt-Rechte | Familien-Vorbild für P2/P4 |
| **DEV-07** | Shinkan | `check_feature_access` (Mitai-Legacy) explizit verboten | Falsches Subjekt | Beibehalten |
| **DEV-08** | Shinkan | Enforcement oft Probe-only | Rollout-Sicherheit | → Phase 4 bis Datum X |
| **DEV-09** | Shinkan | Inventar-Features live gezählt | Drift-Vermeidung | Abweichung OK; in P2 dokumentieren |
| **DEV-10** | Beide | Kein atomares Check+Increment | Race bei Parallel-Requests | Familien-Backlog; Workaround dokumentieren |
| **DEV-11** | Neue Apps | dürfen **nur** Account **oder** nur Tenant wählen | MVP | Eintrag hier anlegen vor Launch |
**Neue Abweichung:** Zeile in §6.2 + ggf. ein Satz im App-`CLAUDE.md`.
### 6.3 Dokumentierte Ausnahmen (Bypass)
| Ausnahme | Apps | Regel |
|----------|------|-------|
| Plattform-Admin Audit | Shinkan | Quota-Bypass über Grants, nicht pauschal superadmin |
| Admin ohne Auto-Bypass | Mitai | Admins unterliegen Limits (Produktentscheid) |
| Öffentliche Routen ohne Auth | alle | Kein Entitlement-Check |
---
## 7. Mapping: Familien-Begriff ↔ Implementierung
### 7.1 Mitai (Profil P1)
| Familien | Mitai-Implementierung |
|----------|----------------------|
| Account-Features | `features`, `tier_limits`, `user_feature_usage` |
| Account-Tier | `get_effective_tier()`, `access_grants` |
| Check | `check_feature_access(profile_id, feature_id)` |
| Increment | `increment_feature_usage(profile_id, …)` |
| UI | `UsageBadge`, Widget `allowed` |
### 7.2 Shinkan (Profil P2)
| Familien | Shinkan-Implementierung |
|----------|-------------------------|
| Tenant-Features | `club_plan_limits`, `club_feature_usage`, `club_features.py` |
| Tenant-Plan | `get_effective_club_plan(club_id)` |
| Capabilities | `check_capability`, `capabilities` + `rights_registry` |
| Snapshot | `build_me_entitlements()` → `GET /me/entitlements` |
| Governance | `TenantContext`, `club_tenancy` — **kein** Ersatz für Capabilities |
### 7.3 Gemeinsame Muster (copy-ready)
| Muster | Mitai | Shinkan |
|--------|-------|---------|
| Feature-Registry in DB | ✅ | ✅ (`app='shinkan'`) |
| Plan × Feature Matrix | `tier_limits` | `club_plan_limits` |
| Admin-Override | `user_feature_restrictions` | `club_feature_overrides` |
| Promo/Trial | `access_grants` | `club_access_grants` |
| 4-Phasen-Rollout | ✅ | ✅ |
| Registry-first neue IDs | ⚠️ teils hardcoded | ✅ `rights_registrations/` |
---
## 8. Entscheidungsregeln für neue Produkte
### 8.1 Pflicht (alle Apps mit Auth)
- [ ] Session → `profile_id`; kein Client-Header als Autorität
- [ ] Entitlements-Auflösung **eine Funktion pro Ebene** (Capability, Feature)
- [ ] API-Enforcement vor UI-Gate
- [ ] 4-Phasen-Rollout dokumentiert
- [ ] Produkt-Profil (P1–P4) gewählt + Abweichungen in §6.2
### 8.2 Wenn Personal App (P1)
- [ ] Primäres Subjekt = Account
- [ ] `tenant`-Block in API = `null` (DEV-01-Analog)
- [ ] Feature-Registry + Tier-Matrix
### 8.3 Wenn Mandanten-App (P2)
- [ ] Primäres Subjekt = Tenant
- [ ] `TenantContext` + Governance getrennt von Capabilities
- [ ] Capabilities registry-first
- [ ] Account nur Lifecycle (verified, member) — kein Tier nötig (DEV-05-Analog erlaubt)
### 8.4 Wenn Minimal App (P3)
- [ ] Explizit: „keine Limits“ oder nur Boolean-Features — in §6.2 eintragen
- [ ] Kein halbes Mitai-v9c kopieren
---
## 9. Konvergenz-Roadmap (optional, nicht blockierend)
| Schritt | Mitai | Shinkan | Familie |
|---------|-------|---------|---------|
| **Kurz** | Legacy Session-Flags entfernen | `CAPABILITY_ENFORCE` / `CLUB_FEATURE_ENFORCE` Prod | DEV-04, DEV-08 schließen |
| **Mittel** | `/me/entitlements` Account-Block | Snapshot um `tenant`-Typ metadata erweitern | Facade alte APIs |
| **Lang** | Optional `tenant_id` reservieren (null) | Optional Account-Tier für Cross-Sell | Shared package `jinkendo_entitlements` |
| **Vision** | SSO + zentraler Billing | Vereins-Abo Stripe | `CENTRAL_SUBSCRIPTION_SYSTEM` |
**Wichtig:** Konvergenz ist **empfohlen**, nicht Pflicht — solange §6.2 aktuell bleibt.
---
## 10. Anti-Patterns (familienweit verboten)
1. Tier- oder Plan-Namen in React-Komponenten hardcoden
2. Limit-Logik nur im Frontend
3. Shinkan-Vereinslimits über Mitai `check_feature_access(profile_id)`
4. Capability-Check durch Governance ersetzen (oder umgekehrt)
5. Neue Feature-IDs nur in SQL-Migration ohne Registry
6. Enforcement Phase 4 „vergessen“ bei paid Features ohne dokumentierte Probe-Phase
7. Undokumentierte Produkt-Abweichung (nicht in §6.2)
---
## 11. Offene Familien-Entscheidungen (Backlog)
| ID | Frage | Optionen | Default wenn unentschieden |
|----|-------|----------|----------------------------|
| **FD-01** | Atomares check+increment | DB-Lock / Transaction / Queue | Status quo + Retry-Hinweis in Doku |
| **FD-02** | Shared Python-Modul | Monorepo-Paket vs. Copy+Sync | Copy+Sync mit gleicher API-Shape |
| **FD-03** | Capability-Namespace global | `jinkendo.*` vs. app-prefix | `{app}.{domain}.{action}` |
| **FD-04** | Mitai bekommt Capability-Layer? | ja/nein/später | nein (DEV-02) bis Bedarf |
| **FD-05** | Ein `features.app` für alle Apps | gemeinsame DB vs. pro Deploy | pro Deploy (heute) |
---
## 12. Verwandte Dokumente
| Dokument | Inhalt |
|----------|--------|
| [DESIGN_PRINCIPLES_ALIGNMENT.md](./DESIGN_PRINCIPLES_ALIGNMENT.md) | Vollständiger Mitai ↔ Shinkan Abgleich |
| [design-principles/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md](./design-principles/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md) | Shinkan Ist-Prinzipien |
| [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./design-principles/ACCESS_LAYER_DESIGN_PRINCIPLES.md) | Governance (Ebene D) |
| Mitai Foundation #3 | Feature & Entitlement Ist Mitai |
| Shinkan `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Vereins-Abo Detail |
| Shinkan `CAPABILITY_CATALOG.v1.md` | Capability-IDs |
---
## 13. Changelog
| Datum | Änderung |
|-------|----------|
| 2026-07-04 | Entwurf: Familien-Standard, Produkt-Profile P1–P4, Abweichungs-Katalog DEV-01–11 |

View File

@ -1,48 +0,0 @@
# Jinkendo Produktfamilie – Dokumentation
**Zweck:** Querschnitts-Dokumentation, die **über einzelne Apps hinaus** gilt (Mitai, Shinkan, künftige Schwester-Produkte).
**Stand:** 2026-07-04
---
## Inhalt
| Verzeichnis | Zweck |
|-------------|--------|
| [design-principles/](./design-principles/) | Extrahierte **Designprinzipien** pro Modul — Foundation für neue Apps und Familien-Review |
---
## Designprinzipien
**Einstieg Shinkan:** [design-principles/DESIGN_PRINCIPLES_INDEX.md](./design-principles/DESIGN_PRINCIPLES_INDEX.md)
**Einstieg Mitai (Foundation):** [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md)
**Abgleich & Entscheidungen:** [DESIGN_PRINCIPLES_ALIGNMENT.md](./DESIGN_PRINCIPLES_ALIGNMENT.md) — Widersprüche, Abweichungen, Familien-Entscheidungs-Matrix
Die Serie dokumentiert:
- **Mitai** (9 Module) — Referenzimplementierung unter `mitai-jinkendo/.claude/docs/jinkendo-foundation/`
- **Shinkan** (15 Module) — in [design-principles/](./design-principles/)
Geplanter nächster Schritt: **FD-***-Entscheidungen im [FAMILY_ENTITLEMENT_MODEL.md](./FAMILY_ENTITLEMENT_MODEL.md) durcharbeiten; Enforcement Phase 4 wo vorgesehen.
| Dokument | Inhalt |
|----------|--------|
| [DESIGN_PRINCIPLES_ALIGNMENT.md](./DESIGN_PRINCIPLES_ALIGNMENT.md) | Mitai ↔ Shinkan Abgleich |
| [FAMILY_ENTITLEMENT_MODEL.md](./FAMILY_ENTITLEMENT_MODEL.md) | **Familien-Standard** Entitlements + Produkt-Abweichungen |
---
## Bezug zu App-spezifischer Doku
| Ebene | Ort (Beispiel Shinkan) |
|-------|-------------------------|
| **Familie** (Prinzipien) | `docs/jinkendo-family/` |
| **App-Architektur** | `docs/architecture/` |
| **Technische Specs** | `.claude/docs/technical/` |
| **Agent-Regeln** | `.claude/rules/`, `CLAUDE.md` |
Implementierungsdetails und Domänen-Specs bleiben in den jeweiligen App-Repositories; Designprinzipien beschreiben **übertragbare Muster** und **bewusste Lücken**.

View File

@ -1,170 +0,0 @@
# Access Layer & Tenant – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Access Layer & Tenant Governance“ — keine Shinkan-Gesamtarchitektur, keine Kampfsport-Domänenlogik
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 1 von 15
**Mitai-Vergleich:** kein direktes Gegenstück (Mitai ist profil-zentriert, kein Vereins-Mandant)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| TenantContext | `backend/tenant_context.py` |
| Governance-Helfer | `backend/club_tenancy.py` |
| Listenfilter SQL | `library_content_visibility_sql()` in `tenant_context.py` |
| Endpoint-Audit | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
| Cursor-Regel | `.cursor/rules/access-layer.mdc` |
| Heuristik-Check | `backend/scripts/check_access_layer_hints.py` |
| Normative Spec | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
---
## Modul
**Access Layer & Tenant Governance**
Zentraler Querschnitt für Mandanten-Kontext (`club_id`), Sichtbarkeit (`private`/`club`/`official`) und einheitliche Les-/Schreibregeln für Bibliotheksartefakte (Übungen, Medien, Rahmenprogramme, Vorlagen, Progressionsgraphen).
---
## Fachliche Verantwortung
1. **TenantContext pro HTTP-Request** — Auflösung aus Session + Header `X-Active-Club-Id` + Profilfeld `active_club_id`.
2. **Datenisolierung** — `club_id` als Grenze für vereinsgeteilte Inhalte; Cross-Verein ausgeschlossen.
3. **Einheitliche Sichtbarkeits-Semantik** — gleiche Enums und Prüflogik über alle Bibliotheksmodule.
4. **Governance-Transitionen** — Regeln beim Wechsel `private` → `club` → `official` und beim Löschen.
5. **Listenfilter** — SQL-Baustein statt „SELECT *“ in jedem Router.
### Administrierte Konfigurationen
| Konfiguration | Speicherort | Inhalt |
|---------------|-------------|--------|
| Aktiver Verein | `profiles.active_club_id` | Persistierter UI-Kontext |
| Request-Override | Header `X-Active-Club-Id` | Client-seitiger Mandantenwechsel |
| Sichtbarkeit | Spalte `visibility` je Objekt | `private`, `club`, `official` |
| Vereinszuordnung | Spalte `club_id` | Pflicht bei `club`-Inhalten |
### Bewusst nicht hardcodiert
- Welche Objekte welchen Verein haben (Daten)
- Individuelle Freigabeentscheidungen (Workflow)
### Hardcodiert (Code)
- Enum-Werte und Leseregeln in `club_tenancy.py` / `library_content_visibility_sql`
- Plattform-Admin-Ausnahmen (`is_platform_admin`, `is_superadmin`)
- Rollencodes für Schreib-/Löschregeln (`club_admin`, `trainer`, …)
---
## Designprinzipien
### 1. Ein Mandant pro Request (`TenantContext`)
| | |
|---|---|
| **Prinzip** | `Depends(get_tenant_context)` liefert `profile_id`, `global_role`, `effective_club_id`, Mitgliedschaften — einmal pro Request. |
| **Begründung** | Kein verteiltes „Rates“ aus Headers; konsistente Filter in allen Routern. |
| **Quelle** | `tenant_context.py`; `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` §2 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht alle Endpoints migriert; Audit-Tabelle zeigt Restbestand mit nur `require_auth`. |
### 2. `club_id` als Datenisolierungsgrenze
| | |
|---|---|
| **Prinzip** | Vereinsgeteilte Inhalte sind nur für aktive Mitglieder des Objekt-`club_id` lesbar — nie Cross-Verein. |
| **Begründung** | Mandantenfähigkeit für Vereinsplattform; Compliance bei geteilten Trainingsinhalten. |
| **Quelle** | `library_content_visibility_sql()`; Tests `test_access_layer*.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `division`-Verschärfung noch nicht durchgängig; reserviert im Plan. |
### 3. Einheitliche Visibility-Semantik
| | |
|---|---|
| **Prinzip** | `private` \| `club` \| `official` mit gleicher Bedeutung für Übungen, Medien, Rahmen, Module, Graphen. |
| **Begründung** | Nutzer und Trainer verstehen ein Freigabemodell; UI-Feld „Freigabelevel“ durchgängig. |
| **Quelle** | `club_tenancy.py`; `FACHLICHE_NUTZERFUNKTIONEN.md` §4.7 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `community`-Stufe nur dokumentiert, nicht implementiert. |
### 4. Zentraler SQL-Filter für Bibliothekslisten
| | |
|---|---|
| **Prinzip** | Listen nutzen `library_content_visibility_sql(alias, profile_id, role, effective_club_id)` — nicht handgeschriebene WHERE-Kopien. |
| **Begründung** | Drift-Vermeidung; ein Fix gilt für alle Kataloge. |
| **Quelle** | `tenant_context.py`; Router `exercises.py`, `training_framework_programs.py`, … |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Einzelne Legacy-Queries können noch abweichen. |
### 5. Governance-Transitionen explizit prüfen
| | |
|---|---|
| **Prinzip** | Wechsel von `visibility`/`club_id` über `assert_library_content_governance_transition` + `assert_valid_governance_visibility`. |
| **Begründung** | „Privat → Verein teilen“ und „official herabstufen“ sind sicherheitsrelevante Aktionen. |
| **Quelle** | `club_tenancy.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht jedes Modul ruft Transition-Helper bei PATCH auf. |
### 6. Löschregeln nach Visibility-Stufe
| | |
|---|---|
| **Prinzip** | `assert_library_content_deletable`: privat → Ersteller/Vereinsadmin-Kontext; club → Vereinsadmin; official → Plattform-Admin. |
| **Begründung** | Schutz vor versehentlichem Löschen fremder oder offizieller Inhalte. |
| **Quelle** | `club_tenancy.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Medien-Lifecycle hat zusätzliche Stufen (Papierkorb) — Schnittmenge beachten. |
### 7. Aktiver Verein: Header + Profil synchron
| | |
|---|---|
| **Prinzip** | Frontend sendet `X-Active-Club-Id`; Backend validiert gegen Mitgliedschaft; Profil speichert `active_club_id`. |
| **Begründung** | Einheitlicher Mandanten-Kontext über API und UI. |
| **Quelle** | `frontend/src/api/client.js` (`mergeActiveClubHeader`); `profiles` Router |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Onboarding-Nutzer ohne Verein: eingeschränkter Nav-Modus. |
### 8. Plattform-Admin als Audit-Pfad, nicht als Bypass
| | |
|---|---|
| **Prinzip** | Plattform-Admins sehen fremde `club`-Inhalte nur mit expliziter Regel (Mitgliedschaft oder Audit-Ausnahme in SQL). |
| **Begründung** | Superuser-Zugriff ohne Mandanten-Leak in normalen Trainer-Flows. |
| **Quelle** | `library_content_visibility_sql` — `club_ok_plat`-Zweig |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Feinheiten zwischen `admin` und `superadmin` (z. B. `official`, Legal Hold) separat geregelt. |
### 9. Endpoint-Audit als lebendes Inventar
| | |
|---|---|
| **Prinzip** | Jeder sicherheitsrelevante Endpoint-Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`; PR-Checkliste verlangt Update. |
| **Begründung** | Sichtbarkeit des Migrationsstands; kein stilles Ausweichen auf `require_auth` allein. |
| **Quelle** | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`; `check_access_layer_hints.py` |
| **Tragfähigkeit** | **hoch** (prozessual) |
| **Einschränkung** | CI-Strict-Modus optional, nicht überall aktiv. |
---
## Nicht übernehmen
1. **Endpoints nur mit `require_auth`** bei tenant-sensitiven Daten — führt zu IDOR und fehlenden Listenfiltern.
2. **Visibility-Logik pro Router duplizieren** — historische Drift zwischen Übungen und Planung.
3. **`division` vor stabiler Vereins-Isolation** — Reihenfolge im Plan: erst Stufe C, dann D.
4. **Community-Freigabe ohne additive Felder** — würde `club`-Isolation brechen.
5. **Client-seitige Mandantenfilter ohne Server-Enforcement** — UI-Hiding reicht nicht.
---
## Verwandte Dokumentation
- [ACCESS_LAYER_AND_GOVERNANCE_PLAN.md](../../../.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md)
- [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../../../.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,140 +0,0 @@
# AI Prompt Runtime – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „AI Prompt Runtime“ — Shinkan-KI-Schicht, keine Planungs-Gesamtarchitektur
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 5 von 15
**Mitai-Vergleich:** [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/PROMPT_ENGINE_DESIGN_PRINCIPLES.md) (#1)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Laufzeit | `backend/ai_prompt_runtime.py`, `backend/prompt_resolver.py` |
| Domänen-Orchestrierung | `backend/exercise_ai.py`, `backend/planning_exercise_*.py` |
| OpenRouter | `backend/openrouter_chat.py` |
| Admin | `backend/routers/ai_prompts_admin.py` |
| Zielbild | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
| Job-Kontext | `backend/ai_prompt_job.py`, `backend/ai_prompt_context.py` |
---
## Modul
**AI Prompt Runtime**
Schmale Ausführungsschicht für admin-konfigurierbare Prompts in `ai_prompts`: Laden, Mustache-Rendering, Kontext-Arten, OpenRouter-Aufruf. **Kein** vollständiges Unified Prompt System wie Mitai (keine Workflows/Pipelines in Produktion).
---
## Fachliche Verantwortung
1. **Prompt-Laden aus DB** — `load_ai_prompt_row`, `load_and_render_ai_prompt`
2. **Platzhalter-Ersetzung** — Mustache `{{key}}` via `prompt_resolver.py`
3. **Kontext-Arten** — `AiPromptContextKind` trennt Übungs-KI vs. Planungs-KI
4. **Domänen-Builder** — `exercise_ai`, Planungs-Pipelines bauen Variablen-Maps
5. **Admin CRUD + Preview** — ohne LLM in Preview-Pfaden wo vorgesehen
---
## Designprinzipien
### 1. Eine Laufzeit-Fassade für DB-Prompts
| | |
|---|---|
| **Prinzip** | Produktive Aufrufe laden Slugs über `ai_prompt_runtime` — nicht Roh-SQL auf `ai_prompts` in Routern. |
| **Begründung** | Einheitliches inactive-Handling, Modell-Feld, Render-Metadaten. |
| **Quelle** | `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.1; `ai_prompt_runtime.py` |
| **Tragfähigkeit** | **hoch** (Zielrichtung) |
| **Einschränkung** | Planungs-KI hat noch verteilte Orchestratoren; kein einzelner `execute_prompt` wie Mitai. |
### 2. Konfigurierbare Bibliothek in `ai_prompts`
| | |
|---|---|
| **Prinzip** | Template-Texte in DB; Admins ändern ohne Deploy (`ai_prompts_admin`). |
| **Begründung** | Gleiches Familien-Muster wie Mitai Prompt-Bibliothek. |
| **Quelle** | Migration 069+; Admin-UI |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Keine Pipeline/Workflow-Typen; Slugs hardcoded in `context_kind_for_slug`. |
### 3. Kontext-Namespaces statt globaler Platzhalter-Soup
| | |
|---|---|
| **Prinzip** | `AiPromptContextKind` (z. B. `exercise_form_ai`, `planning_exercise_search`) begrenzt erlaubte Builder. |
| **Begründung** | Planungs-Kontext wächst ohne Kollision mit Übungs-Keys. |
| **Quelle** | `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.3 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Noch keine zentrale Platzhalter-Registry wie Mitai — Mustache ad hoc pro Builder. |
### 4. Trennung Template vs. Domänen-Kontext
| | |
|---|---|
| **Prinzip** | UI/Router liefern Pydantic-DTOs → Builder erzeugen `variables`-Map → `render_mustache_template`. |
| **Begründung** | Prompt-Autoren ändern Text, nicht Python in Routern. |
| **Quelle** | `prompt_resolver.py`; `ExerciseFormAiPromptContext` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Große Planungs-Kontexte noch nicht vollständig über DTOs. |
### 5. Transport (OpenRouter) getrennt von Semantik
| | |
|---|---|
| **Prinzip** | `openrouter_chat.py` für HTTP; Validierung/JSON-Parsing in Domänen-Schicht. |
| **Begründung** | Modellwechsel ohne Router-Anpassung. |
| **Quelle** | `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.2 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Modell teils global Env, teils Spalte `openrouter_model` — Konvergenz offen. |
### 6. Reset-to-default für System-Prompts
| | |
|---|---|
| **Prinzip** | `default_template` + Admin-Reset — kein vollständiges Versionsmodell. |
| **Begründung** | Familien-Muster aus Mitai; Schutz vor Fehlkonfiguration. |
| **Quelle** | Migration 069 |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Keine Historie benutzerdefinierter Änderungen. |
### 7. Admin-only Schreiben, authentifiziertes Ausführen
| | |
|---|---|
| **Prinzip** | Prompt-CRUD nur Admin; Ausführung mit Capability + Feature-Kontingent. |
| **Begründung** | Systemkonfiguration vs. Nutzung; Kostenkontrolle. |
| **Quelle** | `ai_prompts_admin.py`; `exercises.ai.suggest` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Enforcement teils noch Probe-Phase. |
### 8. Skill-Retrieval orthogonal zu Prompts
| | |
|---|---|
| **Prinzip** | `ai_skill_retrieval_profiles` steuert Katalog für `{{skills_catalog}}` — unabhängig vom Prompt-Text. |
| **Begründung** | Anweisung vs. Kontextfenster trennbar konfigurierbar. |
| **Quelle** | `AI_PROMPT_TARGET_ARCHITECTURE.md` §3.3 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
---
## Nicht übernehmen (Shinkan-Ist / Mitai-Vermeidung)
1. **Direkte OpenRouter-Calls in Routern** ohne Laufzeit-Schicht — historische Schuld, abbauen.
2. **Hardcodierte Prompt-Strings in Produktion** — nur Fallback/Dev.
3. **Mitai-Workflow-Graph vorreifen** — Shinkan braucht erst Planungs-Kontext-Reife.
4. **Globale Platzhalter-Map ohne Namespace** — Mitai-Lektion `PLACEHOLDER_MAP`-Duplikat.
5. **Fehlende JSON-Schema-Validierung** bei `output_format=json` — Mitai-TODO übernehmen vermeiden.
---
## Verwandte Dokumentation
- [AI_PROMPT_TARGET_ARCHITECTURE.md](../../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md)
- [AI_PROMPT_SYSTEM_SPEC.md](../../../.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md)
- [PLANNING_PROGRESSION_GRAPH_KI.md](../../architecture/PLANNING_PROGRESSION_GRAPH_KI.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,114 +0,0 @@
# Auth & Session – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Auth & Session“ — gemeinsame Mitai-Basis in Shinkan
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 4 von 15
**Mitai-Vergleich:** [AUTH_SESSION_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/AUTH_SESSION_DESIGN_PRINCIPLES.md) (#5)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Auth-Kern | `backend/auth.py` |
| Router | `backend/routers/auth.py`, `profiles.py` |
| Frontend | `frontend/src/context/AuthContext.jsx`, `frontend/src/api/client.js` |
| Account-Lifecycle | `backend/account_lifecycle.py` |
---
## Modul
**Auth & Session**
Token-basierte Server-Sessions (`sessions`-Tabelle), bcrypt-Passwörter, FastAPI-Dependencies `require_auth` / `require_admin`. Geteilter Code mit Mitai (App-Familie).
---
## Fachliche Verantwortung
1. **Login/Logout/Session-Lebensdauer**
2. **Passwort-Hashing** (bcrypt, Legacy-SHA256-Upgrade)
3. **Auth-Dependencies** für Router
4. **Account-States** (Verifizierung, Onboarding-Gates)
---
## Designprinzipien
### 1. Server-Side Sessions mit Token
| | |
|---|---|
| **Prinzip** | `X-Auth-Token` Header → Lookup in `sessions` mit Ablaufzeit. |
| **Begründung** | Widerrufbar; kein JWT-Drift zwischen Apps. |
| **Quelle** | `auth.py` `get_session`, `require_auth` |
| **Tragfähigkeit** | **hoch** (Familien-Standard) |
| **Einschränkung** | Kein Refresh-Token-Rotation-Modell. |
### 2. `require_auth` als separater Depends-Parameter
| | |
|---|---|
| **Prinzip** | `session: dict = Depends(require_auth)` — nie in Header-Default eingebettet. |
| **Begründung** | Bekannter FastAPI-Footgun führt zu ungeschützten Endpoints. |
| **Quelle** | `CLAUDE.md` Kritische Regeln |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Code-Review/Lint erzwingt das nicht automatisch. |
### 3. Profile-ID immer aus Session
| | |
|---|---|
| **Prinzip** | `profile_id` aus `session['profile_id']`, nie aus Client-Header als Autorität. |
| **Begründung** | IDOR-Vermeidung. |
| **Quelle** | Architektur-Regeln; Shinkan ergänzt `TenantContext.profile_id` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Mitai-Dokument nennt Profile-Header-Schwäche — in Shinkan prüfen ob analog. |
### 4. bcrypt für alle Passwort-Operationen
| | |
|---|---|
| **Prinzip** | `hash_pin` / `verify_pin` mit bcrypt; SHA256 nur Legacy-Verify + Upgrade. |
| **Begründung** | Familien-konsistente Kryptografie. |
| **Quelle** | `auth.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 5. Portal-Rollen vs. Vereinsrollen trennen
| | |
|---|---|
| **Prinzip** | `profiles.role` (admin/superadmin/user) ≠ `club_member_roles` im Verein. |
| **Begründung** | Shinkan-Mandantenmodell; Plattform-Admin ≠ Vereins-Trainer. |
| **Quelle** | `club_tenancy.py`; `TenantContext.global_role` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | UI muss beide Ebenen korrekt anzeigen. |
### 6. Account-Lifecycle als Capability-Voraussetzung
| | |
|---|---|
| **Prinzip** | `min_account_state` auf Capabilities (z. B. verifiziertes Mitglied). |
| **Begründung** | Gates vor sensiblen Aktionen ohne Sonderchecks in Routern. |
| **Quelle** | `account_lifecycle.py`; `capabilities.py` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Nicht alle Flows nutzen Lifecycle einheitlich. |
---
## Nicht übernehmen
1. **Auth-Parameter in Header-Defaults vermischen** — dokumentierter Anti-Pattern.
2. **Client-gesteuerte `profile_id`** für Autorisierung.
3. **Shinkan-spezifische Mandantenlogik in `auth.py`** — gehört in `tenant_context.py`.
---
## Verwandte Dokumentation
- [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md)
- Mitai: [AUTH_SESSION_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/AUTH_SESSION_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,125 +0,0 @@
# Capability & Club Features – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Capabilities & Vereins-Feature-Kontingente“ — nicht Billing/Stripe
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 2 von 15
**Mitai-Vergleich:** [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md) (#3)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Capabilities | `backend/capabilities.py` |
| Vereins-Features | `backend/club_features.py` |
| Entitlements-API | `backend/entitlements.py`, `backend/routers/me_entitlements.py` |
| Quota-Bypass | `backend/club_quota_bypass.py` |
| Spez | `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` |
---
## Modul
**Capability & Club Feature Entitlements**
Zwei Schichten: **Capabilities** (darf Nutzer X im Verein Y?) und **Club Features** (Kontingente/Limits pro Verein, Subjekt `club_id`). Zusammenführung in `GET /api/me/entitlements`.
---
## Fachliche Verantwortung
1. **Capability-Checks** — `check_capability`, `probe_capability`, `require_capability` mit Env `CAPABILITY_ENFORCE`.
2. **Vereins-Kontingente** — `probe_club_feature_access`, `consume_club_feature_with_usage` mit Env `CLUB_FEATURE_ENFORCE`.
3. **Entitlements-Snapshot** — Frontend erhält `capabilities` + `features` + Plan für UI-Gating ohne Tier-Logik in Widgets.
4. **Account-Lifecycle** — `min_account_state` blockiert Capabilities vor Verifizierung.
### Unterschied zu Mitai
| Aspekt | Mitai | Shinkan |
|--------|-------|---------|
| Limit-Subjekt | Profil / Subscription | **Verein** (`club_id`) |
| Rollen | Tier + Features | Vereinsrollen + Portal-Rolle |
| Legacy | `check_feature_access` (001) | Explizit **nicht** für Shinkan-Limits nutzen |
---
## Designprinzipien
### 1. Eine Entitlements-API für das Frontend
| | |
|---|---|
| **Prinzip** | `GET /api/me/entitlements?club_id=` liefert Capabilities-Map + Feature-Kontingente + Plan. |
| **Begründung** | Keine Tier-Logik in React-Komponenten; ein Roundtrip pro Mandantenwechsel. |
| **Quelle** | `entitlements.py`, `me_entitlements.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht alle UI-Stellen nutzen Entitlements konsequent. |
### 2. 4-Phasen-Rollout (Probe → Enforce)
| | |
|---|---|
| **Prinzip** | Phase 2: JSON-Log ohne Block; Phase 3+: `CAPABILITY_ENFORCE=1` / `CLUB_FEATURE_ENFORCE=1` → HTTP 403. |
| **Begründung** | Sicheres Einführen ohne Produktions-Crash; Audit vor Hard-Block. |
| **Quelle** | `capabilities.py`, `club_features.py`; Mitai-Vorbild in Spec |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Env-Flags müssen pro Umgebung bewusst gesetzt werden. |
### 3. Capabilities verknüpft mit Features
| | |
|---|---|
| **Prinzip** | Capability kann `linked_feature_id` haben — Kontingent-Check vor Ausführung. |
| **Begründung** | Recht und Limit bleiben getrennt modelliert aber gemeinsam enforcebar. |
| **Quelle** | `capabilities`-Tabelle; `check_capability` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Nicht jede Capability hat linked Feature. |
### 4. Enforcement an der API, nicht in der UI
| | |
|---|---|
| **Prinzip** | Router rufen `require_capability` / `probe_club_feature_access` — UI blendet nur vor. |
| **Begründung** | API ist Source of Truth; UI-Gating allein ist umgehbar. |
| **Quelle** | `exercise_ai.py`, Planungs-KI-Router |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Teilweise noch Probe-only in Prod. |
### 5. Bestands-Features als Live-Zählung
| | |
|---|---|
| **Prinzip** | Inventar-Features (`exercises`, `training_groups`, …) zählen live in DB, nicht nur `club_feature_usage`. |
| **Begründung** | Keine Drift zwischen tatsächlichem Bestand und Usage-Tabelle. |
| **Quelle** | `_INVENTORY_FEATURES` in `club_features.py` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Performance bei großen Vereinen — ggf. Cache nötig. |
### 6. Quota-Bypass für Plattform-Rollen
| | |
|---|---|
| **Prinzip** | Konfigurierbare Bypass-Capabilities (`domain=quota_bypass`) für Support/Admin ohne harte Limits. |
| **Begründung** | Betrieb und Demos ohne Plan-Upgrade. |
| **Quelle** | `club_quota_bypass.py`; `entitlements.py` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Missbrauchsrisiko — nur dokumentierte Grants. |
---
## Nicht übernehmen
1. **Mitai `auth.check_feature_access` für Shinkan-Vereinslimits** — profil-zentriert, falscher Subjekt-Scope.
2. **Tier-Logik in Frontend-Widgets** — gehört in Entitlements-Response.
3. **Enforcement ohne Probe-Phase** — bricht bestehende Vereine ohne Vorwarnung.
4. **Capabilities ohne DB-Sync aus Registry** — siehe Rights-Registry-Dokument.
---
## Verwandte Dokumentation
- [CAPABILITY_CATALOG.v1.md](../../../.claude/docs/technical/CAPABILITY_CATALOG.v1.md)
- [CLUB_MEMBERSHIP_AND_FEATURES.v1.md](../../../.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md)
- [RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md](./RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,117 +0,0 @@
# Content Reports (P-13) – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Content Reports“ — Meldeverfahren, Posteingang, Legal Hold
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 10 von 15
**Mitai-Vergleich:** — (Compliance-spezifisch)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| API | `backend/routers/content_reports.py` |
| Legal Hold | `backend/media_legal_hold.py` |
| Inbox-Integration | `GET /api/me/inbox/content-reports` |
| Frontend | `InboxPage.jsx` |
| Migration | 052, 053 |
---
## Modul
**Content Reports & Compliance (P-13)**
Meldeverfahren für problematische Inhalte (Medien, Übungen), Admin-Posteingang mit Statusworkflow, Priorisierung sensibler Gründe, Anbindung Legal Hold und Medien-Audit.
---
## Designprinzipien
### 1. Eine Tabelle, ein Workflow — keine separate Admin-Queue
| | |
|---|---|
| **Prinzip** | `content_reports` + bestehende Inbox-UI für Änderungsanfragen und Meldungen. |
| **Begründung** | Admin-Arbeit an einem Ort; weniger Navigations-Fragmentierung. |
| **Quelle** | `content_reports.py` Docstring |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Zwei Vorgangstypen in einer UI — klare Typ-Kennzeichnung nötig. |
### 2. Melden optional ohne Auth (eingeschränkt)
| | |
|---|---|
| **Prinzip** | Anonym/offline Meldung für `official`-Medien erlaubt; sonst Auth empfohlen. |
| **Begründung** | DSA-Anforderungen; öffentliche Plattform-Inhalte meldbar. |
| **Quelle** | Router-Berechtigungen |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Missbrauchsschutz (Rate Limit) prüfen. |
### 3. Priorität bei sensiblen Gründen
| | |
|---|---|
| **Prinzip** | `minors`, `illegal_content`, `youth_protection` → HIGH_PRIORITY automatisch. |
| **Begründung** | SLA und Admin-Aufmerksamkeit fachlich korrekt. |
| **Quelle** | `HIGH_PRIORITY_REASONS` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 4. Rollengetrennte Sicht (Plattform vs. Verein)
| | |
|---|---|
| **Prinzip** | Plattform-Admin: alle; Club-Admin: nur Vereinsmedien-Meldungen. |
| **Begründung** | Mandanten-Grenze auch im Compliance-Kontext. |
| **Quelle** | Router-Listenfilter |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 5. Legal Hold nur Superadmin aus Meldung
| | |
|---|---|
| **Prinzip** | Mapping `report_reason` → `legal_hold reason_code`; `set_legal_hold` superadmin-geschützt. |
| **Begründung** | Hochrisiko-Aktion; Anschluss P-11. |
| **Quelle** | `_REASON_TO_HOLD_CODE`; `media_legal_hold.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 6. Audit-Spur bei Medien-Meldungen
| | |
|---|---|
| **Prinzip** | `media_asset_audit_log` Event `content_report_filed` bei media_asset-Bezug. |
| **Begründung** | Lifecycle-Entscheidungen nachvollziehbar. |
| **Quelle** | Migration 053; `write_audit_log_entry` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 7. E-Mail best-effort, kein Hard-Fail
| | |
|---|---|
| **Prinzip** | Bestätigung an Melder + Admin-Benachrichtigung; SMTP-Fehler blockieren Speichern nicht. |
| **Begründung** | Meldung geht nicht verloren wenn Mail down. |
| **Quelle** | Router-Implementierung |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Admins müssen Posteingang auch ohne Mail prüfen. |
---
## Nicht übernehmen
1. **Separate Compliance-Queue-App** — Inbox-Reuse ist bewusst.
2. **Legal Hold durch Club-Admin** — Superadmin-only.
3. **Meldungen ohne Statusworkflow/Archiv** — Wiedereröffnen muss möglich sein.
4. **Fehlende Verknüpfung Medien-Lifecycle** — Hold muss Purge blockieren.
---
## Verwandte Dokumentation
- [MEDIA_ASSETS_DESIGN_PRINCIPLES.md](./MEDIA_ASSETS_DESIGN_PRINCIPLES.md)
- [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,105 +0,0 @@
# Dashboard KPIs – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Dashboard KPI Aggregation“ — vereinfacht vs. Mitai Widgets
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 14 von 15
**Mitai-Vergleich:** [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md) (#7, vereinfacht)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| API | `backend/routers/dashboard.py` — `GET /api/dashboard/kpis` |
| Frontend | Dashboard/Übersicht-Page |
| Refaktor-Kontext | `docs/architecture/SCHULDEN_UND_REMEDIATION.md` A3, B1 |
---
## Modul
**Dashboard KPI Aggregation**
Ein Backend-Roundtrip liefert Übungs-KPIs, YTD-Einheiten, Trainings-Home (nächste Termine, Vermerke, offene Rückschau) — Ersatz für mehrere parallele Client-Listen-Calls.
---
## Designprinzipien
### 1. Aggregierter Endpoint statt Chatty Client
| | |
|---|---|
| **Prinzip** | `GET /dashboard/kpis` ruft intern `list_exercises_like_get` + `list_training_units` mit gleichen Filtern wie zuvor im UI. |
| **Begründung** | Weniger Latenz; eine TenantContext-Auflösung; Refaktor Phase 1 Dashboard. |
| **Quelle** | `dashboard.py`; SCHULDEN A3/B1 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Noch keine konfigurierbaren Widgets wie Mitai. |
### 2. Gleiche Filtersemantik wie Einzel-Endpoints
| | |
|---|---|
| **Prinzip** | KPI-Zählungen nutzen dieselben Helfer wie `/exercises` und `/training-units` — keine zweite Query-Logik. |
| **Begründung** | Zahlen auf Dashboard = Zahlen in Fachmodulen. |
| **Quelle** | Import aus `exercises`, `training_planning` Routern |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Interne Funktionsaufrufe statt HTTP — Kopplung an Router-Helfer. |
### 3. TenantContext für Mandanten-KPIs
| | |
|---|---|
| **Prinzip** | `Depends(get_tenant_context)` — assigned_to_me, created_by_me respektieren Verein/Rolle. |
| **Begründung** | Keine globalen KPIs für Trainer fremder Vereine. |
| **Quelle** | `get_dashboard_kpis` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 4. Festes Dashboard-Layout (kein Widget-Katalog)
| | |
|---|---|
| **Prinzip** | Shinkan-Übersicht zeigt definierte Kacheln — nicht nutzerkonfigurierbares Layout-JSON. |
| **Begründung** | MVP-Fokus Trainer-Verein; weniger Komplexität als Mitai. |
| **Quelle** | Produktentscheidung vs. Mitai #7 |
| **Tragfähigkeit** | **mittel** (bewusste Vereinfachung) |
| **Einschränkung** | Erweiterung braucht Backend+Frontend-Change, nicht Admin-Config. |
### 5. Profil nicht redundant laden
| | |
|---|---|
| **Prinzip** | Dashboard soll Auth-Profil nutzen — kein zweites `/profiles/me` nach Login+Reload (E2E Test 8). |
| **Begründung** | Architekturschuld A3 explizit adressiert. |
| **Quelle** | `tests/dev-smoke-test.spec.js`; Roadmap |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Frontend-Umsetzung muss mitziehen. |
### 6. Slice-Logik für Trainings-Home im Backend
| | |
|---|---|
| **Prinzip** | `_slice_training_home_notes` filtert Einheiten mit Vermerken — max. N Stück serverseitig. |
| **Begründung** | Kleiner Payload; klare Semantik. |
| **Quelle** | `dashboard.py` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Grenzwert hardcoded — ggf. Query-Param später. |
---
## Nicht übernehmen
1. **Drei parallele fast gleiche `listTrainingUnits`-Calls** im Client — behoben durch KPI-Endpoint.
2. **Mitai Widget-Dual-Registry vorreifen** ohne Produktbedarf — Over-Engineering für Shinkan MVP.
3. **KPI-Berechnung im Frontend** aus Volllisten — skaliert nicht.
4. **Dashboard ohne Tenant-Filter** — Mandanten-Leak.
---
## Verwandte Dokumentation
- [docs/architecture/SCHULDEN_UND_REMEDIATION.md](../../architecture/SCHULDEN_UND_REMEDIATION.md)
- Mitai: [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,143 +0,0 @@
# Designprinzipien – Index (Jinkendo Produktfamilie)
**Status:** Arbeitspapier / Übergabe
**Stand:** 2026-07-04
**Zweck:** Zentraler Einstieg für **tragfähige Designprinzipien** der Jinkendo-Produktfamilie — Shinkan-Serie (15 Module), Abgleich mit Mitai (9 Module), Basis für Schwester-Apps.
**Nicht enthalten:** App-Gesamtarchitektur, domänenspezifische Fachlogik im Detail, vollständige API-Referenz.
**Ablage (Shinkan-Serie):** `docs/jinkendo-family/design-principles/*_DESIGN_PRINCIPLES.md`
**Übergeordnet:** [docs/jinkendo-family/README.md](../README.md)
**Mitai-Serie:** [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md)
**Abgleich Mitai ↔ Shinkan:** [DESIGN_PRINCIPLES_ALIGNMENT.md](../DESIGN_PRINCIPLES_ALIGNMENT.md)
---
## Wofür diese Serie?
Shinkan implementiert wiederkehrende **Querschnittsmuster** (Mandanten-Zugriff, Capabilities, Medien-Archiv, Planungsdomäne, KI-Laufzeit, Import, Navigation, Deploy) sowie **domänenspezifische Bausteine** (Übungskatalog, Fähigkeiten-Scoring, Trainingsplanung, Compliance-Meldungen). Die 15 Dokumente destillieren daraus:
- **Was** übertragbar ist (Prinzip + Begründung + Tragfähigkeit)
- **Was** bewusst nicht kopiert werden soll („Nicht übernehmen“)
- **Wo** im Code nachgeschaut werden kann (Pfade, Specs)
- **Mitai-Abgleich** — welches Schwester-Dokument vergleichbar ist
Jedes Dokument ist **eigenständig lesbar**; dieser Index ordnet Abhängigkeiten, Lese-Reihenfolge und den geplanten Familien-Review.
---
## Dokumente (15/15)
| # | Modul | Datei | Kernidee (1 Satz) | Mitai-Vergleich |
|---|-------|-------|-------------------|-----------------|
| 1 | Access Layer & Tenant | [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md) | Ein `TenantContext` pro Request; einheitliche `visibility`/`club_id`-Semantik für Bibliotheksartefakte. | — (Shinkan-spezifisch) |
| 2 | Capability & Club Features | [CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md](./CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md) | Capabilities + Vereins-Kontingente; `GET /me/entitlements`; 4-Phasen-Rollout mit Env-Flags. | #3 Feature & Entitlement |
| 3 | Rights Registry | [RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md](./RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md) | Module registrieren Capabilities/Features bei Startup — kein vollständiger Vorab-Katalog in Migrationen. | #4 Registry / Plugin |
| 4 | Auth & Session | [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md) | Server-Sessions, `require_auth` als Depends; gemeinsame Mitai-Basis. | #5 Auth & Session |
| 5 | AI Prompt Runtime | [AI_PROMPT_RUNTIME_DESIGN_PRINCIPLES.md](./AI_PROMPT_RUNTIME_DESIGN_PRINCIPLES.md) | Schmale Laufzeit (`ai_prompt_runtime`); DB-Templates + Mustache; Kontext-Arten pro Domäne. | #1 Prompt Engine |
| 6 | Media Assets & Archiv | [MEDIA_ASSETS_DESIGN_PRINCIPLES.md](./MEDIA_ASSETS_DESIGN_PRINCIPLES.md) | Physisches Asset einmal, mehrfach verknüpft; Lifecycle, Legal Hold, Inline-Rich-Text. | — |
| 7 | Exercise Catalog | [EXERCISE_CATALOG_DESIGN_PRINCIPLES.md](./EXERCISE_CATALOG_DESIGN_PRINCIPLES.md) | Übung als Kernobjekt; Varianten, Governance, Progressionsgraph, Kombinationsübungen. | — |
| 8 | Skill Scoring | [SKILL_SCORING_DESIGN_PRINCIPLES.md](./SKILL_SCORING_DESIGN_PRINCIPLES.md) | Regelbasiertes gewichtetes Profil; Peer-Vergleich nur unter gleichem Artefakttyp. | #2 Data Layer (teilweise) |
| 9 | Training Planning | [TRAINING_PLANNING_DESIGN_PRINCIPLES.md](./TRAINING_PLANNING_DESIGN_PRINCIPLES.md) | Einheiten mit Phasen/Streams; Rahmen-Bibliothek + Module; Coach/Durchführung getrennt. | — |
| 10 | Content Reports (P-13) | [CONTENT_REPORTS_DESIGN_PRINCIPLES.md](./CONTENT_REPORTS_DESIGN_PRINCIPLES.md) | Melde-Workflow in Posteingang; Priorität sensibler Gründe; Legal-Hold-Anschluss. | — |
| 11 | Wiki Import | [WIKI_IMPORT_DESIGN_PRINCIPLES.md](./WIKI_IMPORT_DESIGN_PRINCIPLES.md) | SMW-API-Ingest + Mapper; Preview/Dry-Run; Duplikat-Tracking — kein Raw-Wiki in DB. | #6 Universal Import |
| 12 | Navigation / IA | [NAVIGATION_IA_DESIGN_PRINCIPLES.md](./NAVIGATION_IA_DESIGN_PRINCIPLES.md) | `appNav.js` als SSoT; Admin-Hub horizontal; Onboarding-Nav ohne Verein. | #8 Navigation / IA |
| 13 | Migration & Deploy | [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](./MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) | `XXX_*.sql` beim Container-Start; develop/main → Dev/Prod; fail-fast. | #9 Migration & Deploy |
| 14 | Dashboard KPIs | [DASHBOARD_KPI_DESIGN_PRINCIPLES.md](./DASHBOARD_KPI_DESIGN_PRINCIPLES.md) | Aggregierter `/dashboard/kpis`-Roundtrip statt mehrerer Listen-Calls. | #7 Dashboard Widgets (vereinfacht) |
| 15 | Maturity Models | [MATURITY_MODELS_DESIGN_PRINCIPLES.md](./MATURITY_MODELS_DESIGN_PRINCIPLES.md) | Kontextsensitive Matrix-Auflösung; Export/Import-Stack für Admin-Portabilität. | — |
---
## Empfohlene Lesereihenfolge
### Schnellüberblick (45 Min)
1. [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md) — Shinkan-Kernunterscheidung zu Mitai
2. [RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md](./RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md) — Meta-Muster für Erweiterbarkeit
3. [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](./MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) — Familien-Basis
### Vollständige Implementierung (neues Produkt)
```
Foundation: (13) Migration & Deploy → (4) Auth → (1) Access Layer → (2) Capabilities → (3) Registry
Domäne: (7) Exercise Catalog → (6) Media → (9) Training Planning → (8) Skill Scoring
Erweiterung: (5) AI Prompt Runtime → (11) Wiki Import → (15) Maturity Models
Compliance: (10) Content Reports
Oberfläche: (12) Navigation → (14) Dashboard KPIs
```
### Nur Familien-Review (Mitai ↔ Shinkan)
| Mitai-Dokument | Shinkan-Gegenstück | Review-Fokus |
|----------------|-------------------|--------------|
| #1 Prompt Engine | #5 AI Prompt Runtime | Executor-Reife, Registry, Workflows |
| #2 Data Layer | #8 Skill Scoring | Berechnungs-SSoT vs. Router-Duplikat |
| #3 Feature & Entitlement | #2 Capability & Club Features | Subjekt: Profil vs. Verein |
| #4 Registry | #3 Rights Registry | Registrierungsmuster |
| #5 Auth | #4 Auth & Session | Gemeinsamer Code, IDOR-Risiken |
| #6 Universal Import | #11 Wiki Import | Ingest ≠ Interpretation |
| #7 Dashboard Widgets | #14 Dashboard KPIs | Konfigurierbarkeit vs. Aggregation |
| #8 Navigation | #12 Navigation | appNav-Pattern |
| #9 Migration & Deploy | #13 Migration & Deploy | Gleiches Startup-Muster |
---
## Querschnittsthemen (über alle Docs)
| Thema | Primär | Ergänzend |
|-------|--------|-----------|
| Mandanten-Isolation (`club_id`) | #1 Access Layer | #6 Media, #7 Exercise, #9 Planning |
| Sichtbarkeit `private`/`club`/`official` | #1 Access Layer | #6 Media, #7 Exercise |
| Capability-Gating | #2 Entitlement | #3 Registry, #5 AI Runtime |
| Validierung an der Grenze | #3 Registry | #6 Inline-Media, #11 Import-Mapper |
| Single Source of Truth (Berechnung) | #8 Skill Scoring | #5 AI Kontext-Builder |
| Dual Registry (Code + DB) | #3 Rights Registry | #2 Capabilities in DB |
| Bekannte Lücken dokumentieren | alle „Nicht übernehmen“ | Endpoint-Audit, Architekturschuld |
---
## Verwandte normative Docs (Shinkan-spezifisch)
| Thema | Agent-Guide / Spec |
|-------|-------------------|
| Zugriffsschicht | [ACCESS_LAYER_AND_GOVERNANCE_PLAN.md](../../../.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md), [ACCESS_LAYER_ENDPOINT_AUDIT.md](../../../.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md) |
| Capabilities | [CAPABILITY_CATALOG.v1.md](../../../.claude/docs/technical/CAPABILITY_CATALOG.v1.md) |
| Vereins-Features | [CLUB_MEMBERSHIP_AND_FEATURES.v1.md](../../../.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md) |
| Medien | [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../../../.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) |
| KI-Zielbild | [AI_PROMPT_TARGET_ARCHITECTURE.md](../../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) |
| Planung Streams | [PARALLEL_TRAINING_STREAMS_SPEC.md](../../../.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md) |
| Skill Scoring | [SKILL_SCORING_SPEC.md](../../../.claude/docs/technical/SKILL_SCORING_SPEC.md) |
| Architektur-Schuld | [docs/architecture/SCHULDEN_UND_REMEDIATION.md](../../architecture/SCHULDEN_UND_REMEDIATION.md) |
---
## Übergabe-Checkliste (Familien-Review)
```
[ ] Pro Modul: Prinzipien vs. Mitai-Gegenstück abgleichen
[ ] Architekturschuld pro Modul in SCHULDEN_UND_REMEDIATION / „Nicht übernehmen“ verknüpfen
[ ] Gemeinsame Familien-Prinzipien aus Übereinstimmungen ableiten
[ ] Abweichungen bewusst dokumentieren (z. B. Vereins- vs. Profil-Entitlements)
[ ] Shared Code (auth.py, db_init) — eine Quelle oder Fork-Drift?
```
---
## Pflege
| Aktion | Wo |
|--------|-----|
| Neues Querschnittsmodul extrahiert | Neues `*_DESIGN_PRINCIPLES.md` + Zeile in Tabelle oben |
| Shinkan-Implementierung ändert Muster | Betroffenes Einzeldokument + ggf. Querschnittstabelle |
| Mitai-Review abgeschlossen | Abschnitt „Familien-Prinzipien“ (separates Doc, Backlog) |
---
## Changelog Index
| Datum | Änderung |
|-------|----------|
| 2026-07-04 | Verweis auf [FAMILY_ENTITLEMENT_MODEL.md](../FAMILY_ENTITLEMENT_MODEL.md) |
| 2026-07-04 | Verweis auf [DESIGN_PRINCIPLES_ALIGNMENT.md](../DESIGN_PRINCIPLES_ALIGNMENT.md) |
| 2026-07-04 | Shinkan-Serie nach `docs/jinkendo-family/design-principles/` verschoben (Familien-Foundation) |
| 2026-07-04 | Index angelegt; Serie 1–15 aus Shinkan-Ist-Stand extrahiert |

View File

@ -1,128 +0,0 @@
# Exercise Catalog – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Exercise Catalog“ — Kernobjekt Übung, Varianten, Graphen, Kombination
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 7 von 15
**Mitai-Vergleich:** — (domänenspezifisch)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| API | `backend/routers/exercises.py`, `exercise_progression_graphs.py` |
| Rich-Text | `backend/exercise_rich_text.py` |
| KI | `backend/exercise_ai.py` |
| Frontend | `frontend/src/pages/Exercises*.jsx`, Tab-Formular |
| Specs | `EXERCISES_ARCHITECTURE.md`, `EXERCISES_API_SPEC.md`, Kombinations-Spec |
---
## Modul
**Exercise Catalog**
Shinkans Kernobjekt: Übungen mit mehrdimensionaler Einordnung (Skills, Fokus, Stile), Varianten, Progressionsgraphen, Kombinationsübungen (`method_archetype`, Stationen), Governance und Medien-Anbindung.
---
## Designprinzipien
### 1. Übung als zentrales Aggregate Root
| | |
|---|---|
| **Prinzip** | Varianten, Medien, Skills, Graph-Knoten hängen an `exercises.id` — Owner/Governance auf Eltern-Übung. |
| **Begründung** | Eine Freigabe- und Lösch-Semantik; Varianten ohne eigenen Owner. |
| **Quelle** | `FACHLICHE_NUTZERFUNKTIONEN.md` §4.1, §4.7 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Große Monolith-Router/Seiten — Refaktor-Roadmap. |
### 2. Tab-Formular statt Scroll-Monolith
| | |
|---|---|
| **Prinzip** | Register: Stammdaten · Anleitung · Einordnung · Kombination · Varianten · Medien — Varianten/Medien erst nach erstem Save. |
| **Begründung** | UX für komplexe Objekte; klare Abhängigkeiten (IDs für Medien/Varianten). |
| **Quelle** | Nutzerfunktionen §4.1 |
| **Tragfähigkeit** | **hoch** (UI-Muster) |
| **Einschränkung** | Frontend-God-Page-Schuld dokumentiert. |
### 3. Mehrdimensionale Filter-SSoT
| | |
|---|---|
| **Prinzip** | Suche/Filter über Skills, Fokus, Stil, Zielgruppe, Status, Freigabelevel — Backend-Query + gespeicherte Präferenzen. |
| **Begründung** | Trainer finden Inhalte in großen Vereins-Katalogen. |
| **Quelle** | `SEARCH_FILTER_SPEC.md`; `exercises.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Performance schwere Listen — Baseline/Roadmap. |
### 4. Varianten mit Voraussetzungskette
| | |
|---|---|
| **Prinzip** | `exercise_variants` mit Reihenfolge, optional `prerequisite_variant_id`. |
| **Begründung** | Didaktische Abstufung innerhalb einer Übung. |
| **Quelle** | Migration 030; Planung nutzt Varianten-ID pro Eintrag |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 5. Progressionsgraph als gerichtete Übungs-Beziehungen
| | |
|---|---|
| **Prinzip** | Knoten = Übungen/Varianten; Kanten = „weiter“-Beziehungen; eigener Router + UI in Übungswelt. |
| **Begründung** | Didaktik und Planungs-KI nutzen denselben Graph. |
| **Quelle** | `exercise_progression_graphs.py`; `PLANNING_PROGRESSION_GRAPH_KI.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Graph-Editor-Komplexität; KI-Artefakte separat. |
### 6. Kombinationsübungen als Sonderform im gleichen Katalog
| | |
|---|---|
| **Prinzip** | `exercise_type=combination` mit Stationen, `method_archetype`, optionalem `method_profile`. |
| **Begründung** | In Planung wie normale Übung; Coach zeigt Stations-Layer. |
| **Quelle** | Migration 056/057; Kombinations-Spec V2 |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Archetyp-Stufen B/C noch ausbaubar. |
### 7. Governance integriert (nicht separates CMS)
| | |
|---|---|
| **Prinzip** | `visibility`, `status` (draft/review/…), Access-Layer-Lösch/Transition-Regeln. |
| **Begründung** | Trainer-Workflow ohne externes Freigabe-Tool. |
| **Quelle** | `club_tenancy.py`; Content Change Requests → Posteingang |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Formales Review-Workflow noch leichtgewichtig. |
### 8. Rich-Text-Felder mit Inline-Medien
| | |
|---|---|
| **Prinzip** | Einheitliche Platzhalter/Render für `summary`, `goal`, `execution`, … |
| **Begründung** | Medienreicher Inhalt ohne iframe-Split. |
| **Quelle** | `exercise_rich_text.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
---
## Nicht übernehmen
1. **Varianten mit eigenem Owner/Freigabe** — widerspricht Domänenmodell.
2. **Übungsliste ohne Tenant-Filter** — Access-Layer-Pflicht.
3. **KI-generierte Übungen ohne Governance-Felder** — immer draft/private Default.
4. **Progressionsgraph-Logik im Frontend allein** — Server validiert Kanten.
---
## Verwandte Dokumentation
- [EXERCISES_ARCHITECTURE.md](../../../.claude/docs/technical/EXERCISES_ARCHITECTURE.md)
- [MEDIA_ASSETS_DESIGN_PRINCIPLES.md](./MEDIA_ASSETS_DESIGN_PRINCIPLES.md)
- [SKILL_SCORING_DESIGN_PRINCIPLES.md](./SKILL_SCORING_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,117 +0,0 @@
# Maturity Models – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Maturity Models / Fähigkeitsmatrix“ — kontextsensitive Auflösung
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 15 von 15
**Mitai-Vergleich:** — (domänenspezifisch)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| API | `backend/routers/maturity_models.py`, `matrix_editor.py`, `matrix_stack_bundle.py` |
| Admin-UI | `/admin/maturity-models` |
| Import | Wiki-Import Typ Modelle; Matrix-Stack Export/Import |
| Spec | `.claude/docs/technical/SKILLS_MATRIX_SPEC.md` |
---
## Modul
**Maturity Models & Matrix Stack**
Matrixbasierte Reifegradmodelle mit Stufen und Zelltexten; kontextsensitive Auflösung über Bindings (Fokusbereich, Stilrichtung, Zielgruppe); Admin-Export/Import einzelner Modelle und Komplett-Stack.
---
## Designprinzipien
### 1. Kontext-Bindings M:N (leer = überall)
| | |
|---|---|
| **Prinzip** | Modell verknüpft mit Fokus/Stil/Zielgruppe; leere Bindings = global gültig. |
| **Begründung** | Ein Stack deckt mehrere Trainingskontexte ab. |
| **Quelle** | `maturity_models.py` `_attach_context` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Auflösungs-Priorität bei mehreren Treffern dokumentieren. |
### 2. Resolve-API für Laufzeit-Nutzung
| | |
|---|---|
| **Prinzip** | Authentifizierte Nutzer listen/auflösen; Admin-only für Roh-ID-GET in Admin-UI. |
| **Begründung** | Trainer sehen passende Matrix; Rohdaten-Edit geschützt. |
| **Quelle** | Router-Docstring; Rollen-Checks |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 3. Matrix-Editor als separates Admin-Tool
| | |
|---|---|
| **Prinzip** | `matrix_editor` Router für Zellbearbeitung — nicht im Trainer-Flow. |
| **Begründung** | Komplexe UI; Plattform-Redaktionsaufgabe. |
| **Quelle** | Admin-Nav „Fähigkeitsmatrix“ |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Frontend-Komplexität — eigene Schuld-Kategorie. |
### 4. Stack-Bundle Export/Import
| | |
|---|---|
| **Prinzip** | `matrix_stack_bundle` — Komplett-Stack zwischen Umgebungen (Dev→Prod, Backup). |
| **Begründung** | Analog Prompt Import/Export — Konfiguration versionierbar außerhalb DB. |
| **Quelle** | Admin-Werkzeuge; Wiki-Import ergänzt |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Kein Diff/Merge wie Mitai Prompt-Import. |
### 5. Plattform-Admin-Schreibschutz
| | |
|---|---|
| **Prinzip** | Schreiben nur `admin`/`superadmin`; Lesen breiter für authentifizierte Nutzer (Resolve). |
| **Begründung** | Offizielle Kompetenzrahmen zentral gepflegt. |
| **Quelle** | `_require_admin` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 6. Integration Wiki-Import für Modelle
| | |
|---|---|
| **Prinzip** | SMW-Kategorie Modelle → Import-Pfad neben Übungen/Skills. |
| **Begründung** | Bestehende Wissensbasis karatetrainer.net nutzen. |
| **Quelle** | `import_wiki.py` `CATEGORY_MODELS` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Gap-Analyse SMW — nicht alle Wiki-Felder gemappt. |
### 7. Orthogonal zu Skill Scoring
| | |
|---|---|
| **Prinzip** | Matrix = beschreibende Stufen; Skill Scoring = gewichtete Übungs-Aggregation — getrennte Module. |
| **Begründung** | Keine Vermischung von Kompetenz-Raster und Trainings-KPI. |
| **Quelle** | Domänen-Trennung in Specs |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | UI kann beides nebenan zeigen — klare Labels nötig. |
---
## Nicht übernehmen
1. **Matrix-Zellen in Übungs-Score-Formel mischen** — ohne fachliche Spec.
2. **Trainer-Edit globaler offizieller Matrizen** — Admin-only.
3. **Import ohne Stack-Integrität** — Bundle-Validierung beachten.
4. **Resolve ohne Kontext-Parameter** wenn Mehrdeutigkeit — falsche Matrix.
---
## Verwandte Dokumentation
- [SKILLS_MATRIX_SPEC.md](../../../.claude/docs/technical/SKILLS_MATRIX_SPEC.md)
- [WIKI_IMPORT_DESIGN_PRINCIPLES.md](./WIKI_IMPORT_DESIGN_PRINCIPLES.md)
- [SKILL_SCORING_DESIGN_PRINCIPLES.md](./SKILL_SCORING_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,119 +0,0 @@
# Media Assets & Archiv – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Media Assets & Archiv“ — physische Medien, Lifecycle, Inline
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 6 von 15
**Mitai-Vergleich:** — (Mitai hat kein vergleichbares Medien-Archiv)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| API | `backend/routers/media_assets.py`, `platform_media_storage.py` |
| Speicher | `backend/media_storage.py`, `MEDIA_ROOT` |
| Rechte/Audit | `backend/media_rights.py`, `media_legal_hold.py` |
| Inline Rich-Text | `backend/exercise_rich_text.py` |
| Retention-Job | `backend/scripts/media_retention_job.py` |
| Spec | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
---
## Modul
**Media Assets & Archiv**
Zentrale Verwaltung physischer Dateien (`media_assets`), Verknüpfung zu Übungen (`exercise_media`), mehrstufiger Lifecycle (Papierkorb, Legal Hold), Inline-Einbettung in Rich-Text.
---
## Designprinzipien
### 1. Physisches Asset einmal, mehrfach verknüpft
| | |
|---|---|
| **Prinzip** | Datei in `media_assets`; Übungen referenzieren via `exercise_media.media_asset_id`. |
| **Begründung** | Keine Dubletten auf Platte; Wiederverwendung im Archiv. |
| **Quelle** | `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §1 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy-Pfade ohne Asset-ID können noch existieren. |
### 2. Gleiche Visibility-Semantik wie Übungen
| | |
|---|---|
| **Prinzip** | `private`/`club`/`official` + `club_id` — Access Layer für Download und Liste. |
| **Begründung** | Ein Freigabemodell für alle Bibliotheksartefakte. |
| **Quelle** | Spec §4; `media_rights.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Promotion Übung→official muss Assets mithheben — UI-Dialog Pflicht. |
### 3. Lifecycle getrennt von Übungs-Verknüpfung
| | |
|---|---|
| **Prinzip** | Verknüpfung in Übung lösen ≠ Asset physisch löschen; Papierkorb-Stufen separat. |
| **Begründung** | Trainer dürfen Link entfernen ohne Archiv-Löschrecht. |
| **Quelle** | Spec §5 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Retention-Job muss in Betrieb überwacht werden. |
### 4. Legal Hold blockiert automatisierten Lifecycle
| | |
|---|---|
| **Prinzip** | `legal_hold` schützt Asset vor Purge; Anbindung an Content Reports (Superadmin). |
| **Begründung** | Compliance bei Meldungen (P-11/P-13). |
| **Quelle** | `media_legal_hold.py`; `content_reports.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 5. Inline-Medien: kanonisches Markup + Validierung
| | |
|---|---|
| **Prinzip** | `{{exerciseMedia:id}}` → `<span data-shinkan-exercise-media="id">`; IDs müssen zur Übung gehören. |
| **Begründung** | Ein Render-Pfad; keine broken References nach Medien-Löschung. |
| **Quelle** | `exercise_rich_text.py`; Spec §11 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Beim Erst-Anlegen der Übung keine Inline-Refs (Chicken-Egg). |
### 6. Speicher-Abstraktion (local + konfigurierbarer Root)
| | |
|---|---|
| **Prinzip** | `get_effective_media_root()` + `library/…`-Pfadkonvention; kein hardcodierter `/app/media` in Routern. |
| **Begründung** | NAS/externer Speicher vorbereitet. |
| **Quelle** | `media_storage.py`; `platform_media_storage` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | S3-Backend noch nicht vollständig. |
### 7. Audit-Log für sensitive Aktionen
| | |
|---|---|
| **Prinzip** | `media_asset_audit_log` bei Meldungen, Hold, kritischen Lifecycle-Events. |
| **Begründung** | Nachvollziehbarkeit für Admins und Compliance. |
| **Quelle** | `media_rights.write_audit_log_entry` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Nicht jede Admin-Aktion geloggt. |
---
## Nicht übernehmen
1. **Download nur mit Übungs-ID ohne Asset-Governance** — Seitenkanal-Risiko.
2. **Copyright leer bei `official`** — Spec verbietet das fachlich.
3. **Physisches Löschen bei Referenzanzahl > 0** — Spec §5.3.
4. **Embed-URLs durch Lifecycle-Purge** — Embeds haben anderen Lebenszyklus.
---
## Verwandte Dokumentation
- [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../../../.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md)
- [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md)
- [CONTENT_REPORTS_DESIGN_PRINCIPLES.md](./CONTENT_REPORTS_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,117 +0,0 @@
# Migration & Deploy – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Migration & Deploy“ — Schema-Evolution, Container-Start
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 13 von 15
**Mitai-Vergleich:** [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) (#9)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| DB-Init | `backend/db_init.py` |
| Migrationen | `backend/migrations/XXX_*.sql` |
| Version | `backend/version.py` (`DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) |
| Docker | `docker-compose.yml`, `docker-compose.dev-env.yml` |
| Deploy | develop → dev.shinkan · main → shinkan (Pi) |
---
## Modul
**Migration & Deploy**
Nummerierte SQL-Migrationen beim Container-Start, Tracking in `schema_migrations`, Git-Branch → Umgebung, fail-fast ohne Auto-Rollback.
---
## Designprinzipien
### 1. Nummerierte Migrationen `XXX_*.sql`
| | |
|---|---|
| **Prinzip** | Nur nummerierte Dateien in `backend/migrations/`; lexikographische Reihenfolge. |
| **Begründung** | Familien-Standard Mitai/Shinkan; vorhersagbare Anwendung. |
| **Quelle** | `db_init.py`; `CLAUDE.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Manuelle Nummern-Kollisionen vermeiden — Team-Disziplin. |
### 2. Startup vor App — Migrationen blockieren Start bei Fehler
| | |
|---|---|
| **Prinzip** | `db_init.py` wartet auf Postgres, wendet fehlende Migrationen an, dann FastAPI. |
| **Begründung** | Keine App mit veraltetem Schema. |
| **Quelle** | Container-Entrypoint / startup |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Kein automatisches Rollback — manuelle Recovery. |
### 3. `schema_migrations` Tracking-Tabelle
| | |
|---|---|
| **Prinzip** | Jede angewendete Datei wird persistiert; Wiederholung überspringt Bekannte. |
| **Begründung** | Idempotenz über Deploys hinweg. |
| **Quelle** | `ensure_migration_table`, `get_applied_migrations` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Geänderte Migration nach Apply — nicht neu ausführen (neue Nummer). |
### 4. `DB_SCHEMA_VERSION` als dokumentierter Stand
| | |
|---|---|
| **Prinzip** | `version.py` führt Schema-Version; MODULE_VERSIONS für Subsysteme. |
| **Begründung** | Support und Handover wissen erwarteten Stand. |
| **Quelle** | `backend/version.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Manuell pflegen bei Migration — Drift möglich. |
### 5. develop/main → Dev/Prod mit festen Ports
| | |
|---|---|
| **Prinzip** | Dev 3098/8098 · Prod 3003/8003 — nie ändern ohne explizite Freigabe. |
| **Begründung** | Deploy-Infrastruktur auf Pi/Synology stabil. |
| **Quelle** | `CLAUDE.md` Deployment |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 6. Neue Spalten nur via Migration
| | |
|---|---|
| **Prinzip** | Kein ad-hoc ALTER in Routern; Coding Rules. |
| **Begründung** | Reproduzierbare Umgebungen. |
| **Quelle** | `.claude/rules/CODING_RULES.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 7. IF NOT EXISTS / defensive SQL wo sinnvoll
| | |
|---|---|
| **Prinzip** | Migrationen tolerant bei Wiederanlauf in Dev — aber Tracking verhindert Doppel-Apply. |
| **Begründung** | Recovery in Entwicklung erleichtern. |
| **Quelle** | Mitai-Migrations-Muster |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Nicht alles idempotent — komplexe Migrationen brauchen Transaktion. |
---
## Nicht übernehmen
1. **Manuelle psql-Schritte in Prod** ohne nummerierte Migration im Repo.
2. **Schema-Drift nur in schema.sql** ohne Migration — Init vs. Upgrade verwechseln.
3. **Auto-Rollback bei fehlgeschlagener Migration** — fail-fast, manuell fixen.
4. **Port-Änderung ohne Infra-Update** — bricht Fritz!Box/NAS-Routing.
---
## Verwandte Dokumentation
- [DATABASE_SCHEMA.md](../../../.claude/docs/technical/DATABASE_SCHEMA.md)
- Mitai: [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,107 +0,0 @@
# Navigation / IA – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Navigation & Information Architecture“
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 12 von 15
**Mitai-Vergleich:** [NAVIGATION_IA_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/NAVIGATION_IA_DESIGN_PRINCIPLES.md) (#8)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Hauptnav | `frontend/src/config/appNav.js` |
| Admin-Nav | `frontend/src/components/AdminPageNav.jsx` |
| Shells | `RequireAdmin`, App-Layout in `App.jsx` |
| Return-Kontext | `.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md` |
| Styles | `frontend/src/app.css` (`.admin-top-nav`, Bottom-Nav) |
---
## Modul
**Navigation / IA**
Single Source of Truth für Hauptnavigation (Mobile Bottom + Desktop Sidebar), separater Admin-Hub, Onboarding-Nav ohne Vereinsfeatures, rollen- und kontextabhängige Einblendung (Posteingang, Admin).
---
## Designprinzipien
### 1. `appNav.js` als SSoT für Hauptnavigation
| | |
|---|---|
| **Prinzip** | `getMainNavItems(isAdmin, opts)` liefert Route, Label, Icon — eine Liste für Mobile und Desktop. |
| **Begründung** | Gleiches Familien-Muster wie Mitai `appNav`; keine divergierenden Nav-Arrays. |
| **Quelle** | `appNav.js` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Tiefe Unterrouten (Übung bearbeiten) nicht in Top-Nav — Shell/Back. |
### 2. Admin als separater Hub
| | |
|---|---|
| **Prinzip** | `/admin/*` mit horizontaler `AdminPageNav` — Plattform-Werkzeuge gebündelt. |
| **Begründung** | Trainer-Nav bleibt schlank; Admin-IA skaliert unabhängig. |
| **Quelle** | `AdminPageNav.jsx` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Admin-Nav hardcoded Array — kein `adminNav.js` SSoT wie Mitai ideal. |
### 3. Onboarding-Nav reduziert
| | |
|---|---|
| **Prinzip** | `getOnboardingNavItems()` — nur Verein + Einstellungen ohne Übungen/Planung. |
| **Begründung** | Nutzer ohne Vereinsmitgliedschaft nicht in leere Bereiche führen. |
| **Quelle** | `appNav.js` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 4. Kontextabhängige Items (Posteingang)
| | |
|---|---|
| **Prinzip** | `showInbox` Flag steuert Posteingang-Eintrag — Berechtigung aus Entitlements/Rolle. |
| **Begründung** | Kein toter Nav-Link für Trainer ohne Inbox-Recht. |
| **Quelle** | `baseItems({ showInbox })` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Logik zur `showInbox`-Setzung in App.jsx pflegen. |
### 5. Responsive: Bottom-Nav Mobile, Sidebar Desktop
| | |
|---|---|
| **Prinzip** | Gleiche Items, unterschiedliche Präsentation; CSS-Variablen für Abstände. |
| **Begründung** | PWA-typisches Muster; 80px Bottom-Padding für Nav. |
| **Quelle** | `app.css`; Design-System in `CLAUDE.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Breakpoint-Konsistenz mit Mitai (1024px) prüfen beim Familien-Review. |
### 6. Return-Kontext für tiefe Bearbeitung
| | |
|---|---|
| **Prinzip** | Spez `NAV_RETURN_CONTEXT_SPEC` — zurück zur Herkunftsliste mit Filter-State. |
| **Begründung** | Übungs-Editor aus Suche/Planung ohne Navigations-Verlust. |
| **Quelle** | Return-Context Spec |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Nicht alle Flows implementiert. |
---
## Nicht übernehmen
1. **Zwei unterschiedliche Nav-Arrays** für Mobile vs. Desktop.
2. **Admin-Routen in Haupt-Bottom-Nav** mischen (außer ein Admin-Einstieg).
3. **Hardcodierte Nav in jeder Page** — zentral in `appNav.js`.
4. **Fehlender Onboarding-Gate** — volle Nav ohne Verein verwirrt.
---
## Verwandte Dokumentation
- [NAV_RETURN_CONTEXT_SPEC.md](../../../.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md)
- Mitai: [NAVIGATION_IA_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/NAVIGATION_IA_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,114 +0,0 @@
# Rights Registry – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Rights Registry“ — Capabilities & Features zur Laufzeit registrieren
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 3 von 15
**Mitai-Vergleich:** [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md) (#4)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Registry-Kern | `backend/rights_registry.py` |
| Modul-Registrierungen | `backend/rights_registrations/` (`exercises.py`, `planning.py`, `platform.py`, `club_creation.py`) |
| Startup-Sync | Import in `backend/main.py` |
| Tests | `backend/tests/test_rights_registry.py` |
---
## Modul
**Rights Registry (Registry-first für Capabilities & Features)**
Module deklarieren bei Implementierung, welche Rechte und Kontingente sie anbieten. Beim App-Start werden Definitionen in die DB synchronisiert (`capabilities`, `features`, Default-Grants).
---
## Fachliche Verantwortung
1. **Runtime-Registrierung** — `register_capability()`, `register_feature()` vor DB-Sync.
2. **Modul-Ownership** — Jedes Feature/Capability trägt `module`-Feld für Admin-Filter „Rollen & Rechte“.
3. **Default Club Grants** — Rollen → Capability-Mapping bei Erst-Sync.
4. **Kein vollständiger Vorab-Katalog in SQL-Migration** — nur was Module wirklich liefern.
---
## Designprinzipien
### 1. Registry-first statt Migrations-Monolith
| | |
|---|---|
| **Prinzip** | Neue Rechte erscheinen durch Code-Registrierung + Startup-Upsert — nicht durch manuelle 079-Katalog-Migration pro Feature. |
| **Begründung** | Modul und Recht entstehen zusammen; weniger vergessene Katalog-Einträge. |
| **Quelle** | `rights_registry.py`; Docstring; `rights_registrations/` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Erste Basismigration seedet noch initiale Zeilen. |
### 2. Modul-Datei pro Domäne
| | |
|---|---|
| **Prinzip** | `rights_registrations/exercises.py` registriert nur Übungs-Rechte; Planung/Platform analog. |
| **Begründung** | Ownership klar; Merge-Konflikte lokalisiert. |
| **Quelle** | `rights_registrations/__init__.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Import-Reihenfolge muss in `main.py` garantiert sein. |
### 3. Frozen Dataclass-Definitionen
| | |
|---|---|
| **Prinzip** | `CapabilityRegistration` / `FeatureRegistration` als immutable `@dataclass(frozen=True)`. |
| **Begründung** | Keine nachträgliche Mutation nach Registrierung. |
| **Quelle** | `rights_registry.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 4. Validierung an der Registrierungsgrenze
| | |
|---|---|
| **Prinzip** | `register_*` wirft bei fehlendem `id` oder `module`. |
| **Begründung** | Fehler beim Import/Startup, nicht erst im Admin-UI. |
| **Quelle** | `rights_registry.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Keine Schema-Validierung für `limit_type`/`reset_period` zur Compile-Zeit. |
### 5. DB als persistierter Katalog, Code als SSoT für neue IDs
| | |
|---|---|
| **Prinzip** | Startup `sync_rights_registry_to_db()` upsertet aus In-Memory-Registry. |
| **Begründung** | Admin-UI liest DB; Entwickler erweitern Code-Registry. |
| **Quelle** | `rights_registry.py`; `main.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Deaktivierte Capabilities in DB vs. fehlende im Code — Reconcile-Policy dokumentieren. |
### 6. Default Grants als Code-Daten
| | |
|---|---|
| **Prinzip** | `default_club_grants: (role_code, capability_id)` pro Capability. |
| **Begründung** | Neue Module bringen sinnvolle Standard-Rollen mit. |
| **Quelle** | `rights_registrations/exercises.py` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Admin-Overrides in DB können bei Re-Sync überschrieben werden — ON CONFLICT-Verhalten beachten. |
---
## Nicht übernehmen
1. **Capabilities nur in SQL-Migration pflegen** — driftet vom implementierten Modul weg.
2. **Registrierung ohne Endpoint-Verdrahtung** — Spec: „nur Rechte mit echter Endpoint-Verdrahtung“.
3. **Zweite Registry-Philosophie** für Custom Roles — gleiche Capability-IDs wiederverwenden (Plan Stufe E).
---
## Verwandte Dokumentation
- [CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md](./CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md)
- [CAPABILITY_CATALOG.v1.md](../../../.claude/docs/technical/CAPABILITY_CATALOG.v1.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,107 +0,0 @@
# Skill Scoring – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Skill Scoring & Profile“ — gewichtete Fähigkeiten-KPIs
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 8 von 15
**Mitai-Vergleich:** [DATA_LAYER_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/DATA_LAYER_DESIGN_PRINCIPLES.md) (#2, analog: Berechnungs-SSoT)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Kern | `backend/skill_scoring.py` |
| Profile-API | `backend/routers/skill_profiles.py` |
| Planungs-Vorschläge | Planung-Router + Fähigkeiten-Seite |
| Spec | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
---
## Modul
**Skill Scoring & Profiles**
Regelbasierte Aggregation von `exercise_skills` über Artefakte (Module, Rahmenprogramme, Pläne, Graphen) zu gewichteten Profilen mit Peer-Vergleich innerhalb desselben Artefakttyps.
---
## Designprinzipien
### 1. Berechnung in einer Schicht (`skill_scoring.py`)
| | |
|---|---|
| **Prinzip** | Scores, Gewichte, Peer-Perzentile — nicht in React oder Router-SQL duplizieren. |
| **Begründung** | Analog Mitai Data Layer: Charts, Listen-KPIs, Planungs-Vorschläge nutzen dieselbe Logik. |
| **Quelle** | `SKILL_SCORING_SPEC.md`; `skill_scoring.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Einzelne UI-Fallbacks können noch vereinfacht rechnen. |
### 2. Gewichtung aus Trainings-Signalen
| | |
|---|---|
| **Prinzip** | Dauer, Vorkommen, Intensität (`niedrig`/`mittel`/`hoch`), Stufen-Spanne — explizite Multiplikatoren. |
| **Begründung** | Nachvollziehbares Ranking ohne Black-Box-ML. |
| **Quelle** | `_INTENSITY_MULT`, `_level_range_multiplier` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | `is_primary` / `development_contribution` bewusst ignoriert. |
### 3. Peer-Vergleich nur unter gleichem Artefakttyp
| | |
|---|---|
| **Prinzip** | Modul vs. Modul, Rahmen vs. Rahmen — nie Modul vs. Plan gemischt. |
| **Begründung** | Fachlich sinnvoller Vergleich; vermeidet irreführende Prozentwerte. |
| **Quelle** | Phase 3 Lieferung; Nutzerfunktionen §4.2 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | UI muss Typ-Kontext klar labeln. |
### 4. Sichtbarkeits-filterte Peer-Menge
| | |
|---|---|
| **Prinzip** | Peer-Pool = nur für Nutzer sichtbare Artefakte (Access Layer). |
| **Begründung** | Keine Leaks über Scores fremder Vereins-Inhalte. |
| **Quelle** | `skill_scoring.py` + Tenant-Filter in Aufrufern |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Performance bei großen Pools. |
### 5. Planungs-Vorschläge aus Profil-Delta
| | |
|---|---|
| **Prinzip** | Fähigkeiten-Schwerpunkte → sortierte Vorschläge für Module/Rahmen/Regressionspfade. |
| **Begründung** | Schließt Loop zwischen Katalog und Planung. |
| **Quelle** | Fähigkeiten-Seite Phase 3 |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | KI-Suche über Volltext — Backlog. |
### 6. Default-Minuten für fehlende Dauer
| | |
|---|---|
| **Prinzip** | `DEFAULT_ITEM_MINUTES` / `GRAPH_DEFAULT_ITEM_MINUTES` als explizite Konstanten. |
| **Begründung** | Deterministische Scores bei unvollständigen Planungsdaten. |
| **Quelle** | `skill_scoring.py` |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Fachlich kalibrierbar — dokumentieren statt verstecken. |
---
## Nicht übernehmen
1. **Score-Berechnung im Frontend** für Listen-KPIs.
2. **Peer-Vergleich über Artefakttypen hinweg** — irreführend.
3. **ML-Black-Box statt regelbasierter Gewichte** — ohne explizite Produktentscheidung.
4. **Ignorieren der Tenant-Sichtbarkeit** im Peer-Pool.
---
## Verwandte Dokumentation
- [SKILL_SCORING_SPEC.md](../../../.claude/docs/technical/SKILL_SCORING_SPEC.md)
- [EXERCISE_CATALOG_DESIGN_PRINCIPLES.md](./EXERCISE_CATALOG_DESIGN_PRINCIPLES.md)
- [TRAINING_PLANNING_DESIGN_PRINCIPLES.md](./TRAINING_PLANNING_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,129 +0,0 @@
# Training Planning – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „Training Planning“ — Einheiten, Phasen, Rahmen, Coach
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 9 von 15
**Mitai-Vergleich:** — (domänenspezifisch)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Kalender-Einheiten | `backend/routers/training_planning.py` |
| Module | `backend/routers/training_modules.py` |
| Rahmen | `backend/routers/training_framework_programs.py` |
| Phasen/Streams | Migration 063; `PARALLEL_TRAINING_STREAMS_SPEC.md` |
| Frontend | `TrainingPlanningPage`, `TrainingCoachPage`, `TrainingUnitRunPage` |
| Utils | `frontend/src/utils/trainingPlanUtils.js` |
---
## Modul
**Training Planning & Frameworks**
Planbare Trainingseinheiten mit Sektionen, **Phasen** (Ganzgruppe/Parallel) und **Streams**, Bibliotheks-**Rahmenprogramme** (Ziele/Slots), **Trainingsmodule**, Materialisierung aus Slots, Durchführungs- und Coaching-Ansichten.
---
## Designprinzipien
### 1. Einheit als planbares Aggregate
| | |
|---|---|
| **Prinzip** | `training_units` + `training_unit_sections` + Items; Kopf: Gruppe, Datum, Trainer, Status. |
| **Begründung** | Klare Grenze Kalender vs. Bibliothek. |
| **Quelle** | Domain Model; `training_planning.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Große Router-Datei — Refaktor-Schuld. |
### 2. Phasen/Streams als explizites Modell (nicht Marker-Sektionen)
| | |
|---|---|
| **Prinzip** | `training_unit_phases` + `training_unit_parallel_streams`; Sektionen an Phase oder Stream gebunden. |
| **Begründung** | Breakout-Trainings fachlich korrekt; Coach/Rejoin-Logik. |
| **Quelle** | `PARALLEL_TRAINING_STREAMS_SPEC.md` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Legacy-Einheiten → Default-Ganzgruppenphase; Vorlagen-Phasen teils offen. |
### 3. API: verschachtelte `phases` + flache `sections`
| | |
|---|---|
| **Prinzip** | GET liefert beides; PUT akzeptiert `phases` atomar; höchstens eines von phases/sections/exercises pro Request. |
| **Begründung** | Frontend normalisiert; Server validiert CHECK-Regeln. |
| **Quelle** | Spec §4; Planning-Router |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Server-Spiegelung neuer Abschnitte in phases — Handover offen. |
### 4. Rahmen-Bibliothek: Slot = Blueprint-Unit
| | |
|---|---|
| **Prinzip** | `framework_slot_id` auf Blueprint-`training_units`; Materialisierung → Kalender-Einheit für Gruppe. |
| **Begründung** | Wiederverwendbare Programme ohne Duplikat-Logik pro Slot-Typ. |
| **Quelle** | Migration 035–037 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | UI „Aus Rahmen übernehmen“ nicht flächendeckend. |
### 5. Trainingsmodule als wiederverwendbare Übungsfolgen
| | |
|---|---|
| **Prinzip** | Bibliotheks-Objekt mit Skill-Profil; Übernahme in geplante Einheit. |
| **Begründung** | Trainer-Bausteine zwischen Einzelübung und Rahmen. |
| **Quelle** | `training_modules.py`; Skill Scoring Phase 3 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 6. Drei Durchführungsmodi getrennt
| | |
|---|---|
| **Prinzip** | Planung (edit) · Plan & Ablauf (run) · Coaching (step timeline, Stream-Picks, Nachbereitung). |
| **Begründung** | Unterschiedliche UX und Payloads; Coach speichert → Run-Ansicht. |
| **Quelle** | Nutzerfunktionen §4.4; `TrainingCoachPage` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Stream-Tabs in Run-Ansicht optional offen. |
### 7. Governance über Gruppe/Verein, keine neuen Mandanten-Entitäten
| | |
|---|---|
| **Prinzip** | Einheit → `training_group` → Verein; Access Layer für Bibliotheks-Rahmen/Module. |
| **Begründung** | Planung erbt Organisations-Kontext. |
| **Quelle** | `PARALLEL_TRAINING_STREAMS_SPEC.md` §4 |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Stream-Trainer-Zuweisung UI unvollständig. |
### 8. Kombinationsübungen in Planung transparent
| | |
|---|---|
| **Prinzip** | Items ohne Variante; Coach zeigt Stations-Kandidaten + Archetyp-Hinweise. |
| **Begründung** | Gleiche Item-Schicht für Standard- und Kombi-Übungen. |
| **Quelle** | Migration 057; Kombinations-Spec Anhang A |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Archetyp-Stufen B/C ausbaubar. |
---
## Nicht übernehmen
1. **Parallele Phasen als reine UI-Konvention ohne DB-Phasen** — Minimalvariante verworfen.
2. **Rahmen-Slots als separate Exercise-Join-Tabelle** — Legacy `training_framework_slot_exercises` abgelöst.
3. **Planungs-KI direkt in Router-Strings** — AI Prompt Runtime nutzen.
4. **Blueprint-Einheiten in Kalenderlisten** — Filter `framework_slot_id IS NOT NULL`.
---
## Verwandte Dokumentation
- [PARALLEL_TRAINING_STREAMS_SPEC.md](../../../.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md)
- [TRAINING_FRAMEWORK_SPEC.md](../../../.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md)
- [SKILL_SCORING_DESIGN_PRINCIPLES.md](./SKILL_SCORING_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -1,118 +0,0 @@
# Wiki Import – Designprinzipien (Extraktion)
**Status:** Analyse / Arbeitspapier
**Stand:** 2026-07-04
**Geltungsbereich:** Modul „MediaWiki Import“ — SMW-Ingest, Mapping, Tracking
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 11 von 15
**Mitai-Vergleich:** [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md) (#6)
**Kernkomponenten:**
| Bereich | Pfade |
|---------|-------|
| Router | `backend/routers/import_wiki.py`, `import_wiki_admin.py` |
| Client | `backend/smw_client.py` |
| Mapper | `backend/smw_mapper.py` |
| Tracking | `wiki_import_log`, `wiki_import_references` |
| Spec | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
---
## Modul
**Wiki Import (Semantic MediaWiki)**
Import von Übungen, Fähigkeiten, Methoden und Reifegradmodellen aus externem Wiki via API — Preview, Dry-Run, Duplikat-Erkennung, Admin-only.
---
## Designprinzipien
### 1. Ingest ≠ Interpretation
| | |
|---|---|
| **Prinzip** | `SmwClient` holt Rohdaten; `smw_mapper` mappt auf Shinkan-Modelle — getrennte Schichten. |
| **Begründung** | Analog Mitai Import: Transport/Parser ≠ Domänen-Insert. |
| **Quelle** | `import_wiki.py`; Mitai Universal Import |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Keine generische Import-Registry wie Mitai CSV — wiki-spezifisch. |
### 2. Preview und Dry-Run vor Execute
| | |
|---|---|
| **Prinzip** | `/preview` zeigt Kandidaten; `dry_run=true` ohne DB-Schreiben. |
| **Begründung** | Admin sieht Auswirkungen; sichere Iteration. |
| **Quelle** | `ImportExecuteRequest` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 3. Duplikat-Tracking über Wiki-Referenzen
| | |
|---|---|
| **Prinzip** | `wiki_import_references` speichert Wiki-Titel ↔ Shinkan-ID für Re-Import. |
| **Begründung** | Idempotenz und Update statt blindem Duplicate. |
| **Quelle** | Domain Model Import-Tabellen |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Gap-Analyse in `SMW_IMPORTER_GAP_ANALYSIS.md` beachten. |
### 4. Import-Typ als expliziter Parameter
| | |
|---|---|
| **Prinzip** | `import_type`: `exercise` \| `skill` \| `method` \| Modelle — eigener Mapper-Pfad. |
| **Begründung** | Klare Verantwortung pro Ziel-Entität. |
| **Quelle** | `map_wiki_to_*` in `smw_mapper.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | Kein Plug-in-Registry-Pattern wie Mitai Module-Registry. |
### 5. Superadmin/Admin-only Execute
| | |
|---|---|
| **Prinzip** | `require_admin` auf Execute — Massenimport ist Plattform-Risiko. |
| **Begründung** | Governance und Datenqualität. |
| **Quelle** | `import_wiki.py` |
| **Tragfähigkeit** | **hoch** |
| **Einschränkung** | — |
### 6. Kategorie aus Env mit Fallback
| | |
|---|---|
| **Prinzip** | `MEDIAWIKI_CATEGORY_*` Env-Variablen; leere Query → Default je Typ. |
| **Begründung** | Wiki-Struktur konfigurierbar ohne Code-Deploy. |
| **Quelle** | `CATEGORY_EXERCISES` etc. |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Hardcoded Wiki-URL in Doku — Umgebungsspezifisch halten. |
### 7. Background Tasks für lange Imports
| | |
|---|---|
| **Prinzip** | FastAPI `BackgroundTasks` für Execute — HTTP nicht blockieren. |
| **Begründung** | Große Kategorien ohne Timeout. |
| **Quelle** | Execute-Endpoint |
| **Tragfähigkeit** | **mittel** |
| **Einschränkung** | Kein Job-Status-Polling-UI wie Mitai — Log-Tabelle nutzen. |
---
## Nicht übernehmen
1. **Rohe Wiki-HTML ungemappt in DB** — immer Mapper.
2. **Import ohne Log/Re-Import-Referenz** — Duplikat-Chaos.
3. **Trainer-self-service Wiki-Import** — Admin-only.
4. **Skill-Scoring beim Insert** — Scores gehören in `skill_scoring`-Schicht.
---
## Verwandte Dokumentation
- [MEDIAWIKI_IMPORT_SPEC.md](../../../.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md)
- [EXERCISE_CATALOG_DESIGN_PRINCIPLES.md](./EXERCISE_CATALOG_DESIGN_PRINCIPLES.md)
- Mitai: [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md)
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)

View File

@ -14,7 +14,6 @@
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"marked": "^18.0.3", "marked": "^18.0.3",
"qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",

View File

@ -21,7 +21,6 @@ import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute' import PlatformAdminRoute from './components/PlatformAdminRoute'
import ActiveClubSwitcher from './components/ActiveClubSwitcher' import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner' import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import TotpSetupCard from './components/TotpSetupCard'
import './app.css' import './app.css'
const OnboardingPage = lazyWithRetry(() => import('./pages/OnboardingPage')) const OnboardingPage = lazyWithRetry(() => import('./pages/OnboardingPage'))
@ -127,29 +126,6 @@ function Nav({ showAdminNav, onboardingOnly }) {
) )
} }
function TotpEnrollGate() {
const { checkAuth, logout } = useAuth()
return (
<div className="page-padding" style={{ maxWidth: 520, margin: '2rem auto' }}>
<h1 style={{ fontSize: '1.4rem' }}>Zwei-Faktor einrichten</h1>
<p style={{ color: 'var(--text2)' }}>
Plattform-Admins melden sich erst an, wenn ein Authenticator eingerichtet ist.
</p>
<TotpSetupCard onEnabled={() => checkAuth()} />
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
await logout()
window.location.href = '/login'
}}
>
Abmelden
</button>
</div>
)
}
function ProtectedLayout() { function ProtectedLayout() {
const { isAuthenticated, loading, user, logout } = useAuth() const { isAuthenticated, loading, user, logout } = useAuth()
@ -179,10 +155,6 @@ function ProtectedLayout() {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
if (user?.totpEnrollmentRequired) {
return <TotpEnrollGate />
}
const location = useLocation() const location = useLocation()
const onboardingOnly = isOnboardingRestricted(user) const onboardingOnly = isOnboardingRestricted(user)
if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) { if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) {

View File

@ -6,7 +6,7 @@ import ExerciseMediaEmbed from './ExerciseMediaEmbed'
import ExerciseMediaThumbTile from './ExerciseMediaThumbTile' import ExerciseMediaThumbTile from './ExerciseMediaThumbTile'
import MediaPreviewModal from './MediaPreviewModal' import MediaPreviewModal from './MediaPreviewModal'
import ReportContentModal from './ReportContentModal' import ReportContentModal from './ReportContentModal'
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl' import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { collectInlineExerciseMediaIdsFromExercise } from '../utils/exerciseInlineMediaRefs' import { collectInlineExerciseMediaIdsFromExercise } from '../utils/exerciseInlineMediaRefs'
function isTrashHidden(m) { function isTrashHidden(m) {
@ -14,7 +14,6 @@ function isTrashHidden(m) {
} }
export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) { export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
const mediaToken = useMediaAccessToken()
const [preview, setPreview] = useState(null) const [preview, setPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null) const [reportTarget, setReportTarget] = useState(null)
const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise]) const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise])
@ -67,7 +66,7 @@ export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
<MediaPreviewModal <MediaPreviewModal
title={(preview.title || '').trim() || preview.original_filename || `Medium #${preview.id}`} title={(preview.title || '').trim() || preview.original_filename || `Medium #${preview.id}`}
media={preview} media={preview}
fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview, mediaToken)} fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview)}
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
onReport={ onReport={
(preview.asset_lifecycle_state || 'active') === 'active' && !preview.asset_legal_hold_active (preview.asset_lifecycle_state || 'active') === 'active' && !preview.asset_legal_hold_active

View File

@ -3,7 +3,7 @@
*/ */
import React, { useEffect, useState, useCallback, useMemo } from 'react' import React, { useEffect, useState, useCallback, useMemo } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { resolveMediaAssetFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl' import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import { import {
INLINE_MEDIA_SIZES, INLINE_MEDIA_SIZES,
DEFAULT_INLINE_MEDIA_SIZE, DEFAULT_INLINE_MEDIA_SIZE,
@ -12,9 +12,8 @@ import {
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption' import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
function RtePickerAssetThumb({ asset }) { function RtePickerAssetThumb({ asset }) {
const mediaToken = useMediaAccessToken()
const id = asset.id const id = asset.id
const src = resolveMediaAssetFileUrl(id, mediaToken) const src = resolveMediaAssetFileUrl(id)
const mt = (asset.mime_type || '').toLowerCase() const mt = (asset.mime_type || '').toLowerCase()
if (mt.startsWith('image/') && src) { if (mt.startsWith('image/') && src) {
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" /> return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl' import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia' import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia'
/** /**
@ -7,7 +7,6 @@ import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia'
* @param {{ media: object, exerciseId: number, layoutSize?: string }} props * @param {{ media: object, exerciseId: number, layoutSize?: string }} props
*/ */
export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'medium' }) { export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'medium' }) {
const mediaToken = useMediaAccessToken()
const sz = sanitizeInlineMediaSize(layoutSize) const sz = sanitizeInlineMediaSize(layoutSize)
const box = const box =
sz === 'small' sz === 'small'
@ -47,7 +46,7 @@ export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'me
</div> </div>
) )
} }
const src = resolveExerciseMediaFileUrl(exerciseId, media, mediaToken) const src = resolveExerciseMediaFileUrl(exerciseId, media)
if (!src) return null if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) { if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return ( return (

View File

@ -2,11 +2,10 @@
* Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img, Embed = Label. * Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img, Embed = Label.
*/ */
import React from 'react' import React from 'react'
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl' import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
export default function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview, size = 72 }) { export default function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview, size = 72 }) {
const mediaToken = useMediaAccessToken() const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media, mediaToken) : null
const commonStyle = { const commonStyle = {
width: '100%', width: '100%',
height: '100%', height: '100%',

View File

@ -1,163 +0,0 @@
import { useEffect, useState } from 'react'
import api from '../utils/api'
/**
* TOTP einrichten: QR-Code und Geheimnis anzeigen, Code bestätigen, Wiederherstellungscodes einmal zeigen.
*/
export default function TotpSetupCard({ onEnabled }) {
const [secret, setSecret] = useState('')
const [otpauthUri, setOtpauthUri] = useState('')
const [qrDataUrl, setQrDataUrl] = useState('')
const [code, setCode] = useState('')
const [recoveryCodes, setRecoveryCodes] = useState([])
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!otpauthUri) {
setQrDataUrl('')
return undefined
}
let cancelled = false
import('qrcode')
.then((mod) => {
const QRCode = typeof mod.toDataURL === 'function' ? mod : mod.default
return QRCode.toDataURL(otpauthUri, {
width: 240,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#000000', light: '#ffffff' },
})
})
.then((url) => {
if (!cancelled) setQrDataUrl(url)
})
.catch(() => {
if (!cancelled) setQrDataUrl('')
})
return () => {
cancelled = true
}
}, [otpauthUri])
const start = async () => {
setError('')
setBusy(true)
try {
const data = await api.setupTotp()
setSecret(data.secret || '')
setOtpauthUri(data.otpauth_uri || '')
setRecoveryCodes([])
} catch (err) {
setError(err.message || 'Einrichtung fehlgeschlagen')
} finally {
setBusy(false)
}
}
const confirm = async (e) => {
e.preventDefault()
setError('')
setBusy(true)
try {
const data = await api.confirmTotp(code.trim())
if (data?.token) localStorage.setItem('authToken', data.token)
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
setSecret('')
setOtpauthUri('')
setCode('')
} catch (err) {
setError(err.message || 'Code nicht akzeptiert')
} finally {
setBusy(false)
}
}
return (
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor-Anmeldung</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', lineHeight: 1.5 }}>
Authenticator-App öffnen, ein Konto hinzufügen und den QR-Code scannen. Wenn das Scannen
nicht klappt, das Geheimnis darunter manuell eintragen.
</p>
{error ? (
<p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>
) : null}
{!secret && recoveryCodes.length === 0 ? (
<button type="button" className="btn btn-primary" onClick={start} disabled={busy}>
{busy ? 'Wird erzeugt…' : 'QR-Code erzeugen'}
</button>
) : null}
{secret ? (
<form onSubmit={confirm}>
{qrDataUrl ? (
<div
style={{
display: 'inline-block',
background: '#ffffff',
padding: 8,
borderRadius: 8,
margin: '0.25rem 0 0.85rem',
}}
>
<img
src={qrDataUrl}
alt="QR-Code zum Einrichten in der Authenticator-App"
width={240}
height={240}
style={{ display: 'block', width: 240, height: 240 }}
/>
</div>
) : null}
<p style={{ fontFamily: 'ui-monospace, monospace', letterSpacing: '0.04em', wordBreak: 'break-all' }}>{secret}</p>
<label className="form-label" htmlFor="totp-confirm-code">
Code aus der App
</label>
<input
id="totp-confirm-code"
className="form-input"
inputMode="numeric"
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: '0.75rem' }}>
{busy ? 'Prüft…' : 'Aktivieren'}
</button>
</form>
) : null}
{recoveryCodes.length > 0 ? (
<div>
<p style={{ fontSize: '0.875rem' }}>
Diese acht Codes jetzt sichern. Die Seite bleibt stehen, bis du unten bestätigst.
Ein Neuladen löscht die Anzeige. Neue Codes kannst du danach in den Einstellungen erzeugen.
</p>
<textarea
readOnly
rows={8}
className="form-input"
value={recoveryCodes.join('\n')}
style={{ fontFamily: 'ui-monospace, monospace', marginBottom: '0.75rem' }}
/>
<button
type="button"
className="btn btn-secondary"
style={{ marginRight: '0.5rem' }}
onClick={() => navigator.clipboard?.writeText(recoveryCodes.join('\n'))}
>
Kopieren
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
if (onEnabled) onEnabled()
}}
>
Ich habe die Codes gespeichert
</button>
</div>
) : null}
</div>
)
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react' import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useNavigate, useParams, Link, useLocation } from 'react-router-dom' import { useNavigate, useParams, Link, useLocation } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../../utils/api' import api, { buildExerciseApiPayload } from '../../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl, useMediaAccessToken } from '../../utils/exerciseMediaUrl' import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl'
import RichTextEditor from '../RichTextEditor' import RichTextEditor from '../RichTextEditor'
import ExerciseProgressionGraphPanel from '../ExerciseProgressionGraphPanel' import ExerciseProgressionGraphPanel from '../ExerciseProgressionGraphPanel'
import ExerciseMediaThumbTile from '../ExerciseMediaThumbTile' import ExerciseMediaThumbTile from '../ExerciseMediaThumbTile'
@ -573,7 +573,6 @@ function detailToForm(exercise) {
} }
function ExerciseFormPageRoot() { function ExerciseFormPageRoot() {
const mediaToken = useMediaAccessToken()
const { id: routeId } = useParams() const { id: routeId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@ -2833,7 +2832,7 @@ function ExerciseFormPageRoot() {
{a.mime_type?.startsWith('image/') ? ( {a.mime_type?.startsWith('image/') ? (
<img <img
alt="" alt=""
src={resolveMediaAssetFileUrl(a.id, mediaToken)} src={resolveMediaAssetFileUrl(a.id)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/> />
) : a.mime_type?.startsWith('video/') ? ( ) : a.mime_type?.startsWith('video/') ? (
@ -2881,7 +2880,7 @@ function ExerciseFormPageRoot() {
<MediaPreviewModal <MediaPreviewModal
title={(mediaPreview.title || '').trim() || mediaPreview.original_filename || `Medium #${mediaPreview.id}`} title={(mediaPreview.title || '').trim() || mediaPreview.original_filename || `Medium #${mediaPreview.id}`}
media={mediaPreview} media={mediaPreview}
fileUrl={mediaPreview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, mediaPreview, mediaToken)} fileUrl={mediaPreview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
onClose={() => setMediaPreview(null)} onClose={() => setMediaPreview(null)}
onReport={ onReport={
!mediaPreview.asset_legal_hold_active !mediaPreview.asset_legal_hold_active

View File

@ -10,7 +10,6 @@ import {
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api' import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub' import { activeClubMemberships } from '../utils/activeClub'
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils' import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
import { clearMediaGrant, refreshMediaGrant } from '../utils/exerciseMediaUrl'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@ -64,25 +63,9 @@ export function AuthProvider({ children }) {
const profile = await api.getCurrentProfile() const profile = await api.getCurrentProfile()
syncStoredActiveClub(profile) syncStoredActiveClub(profile)
setUser(profile) setUser(profile)
refreshMediaGrant().catch(() => {})
} catch (err) { } catch (err) {
try {
const status = await api.getTotpStatus()
if (status?.enrollment_required && !status?.totp_enabled) {
setUser({
totpEnrollmentRequired: true,
email: status.email || null,
role: status.role || null,
})
return
}
} catch {
/* kein Einrichtungs-Token */
}
console.error('Auth check failed:', err) console.error('Auth check failed:', err)
localStorage.removeItem('authToken') localStorage.removeItem('authToken')
clearMediaGrant()
setUser(null)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -149,7 +132,6 @@ export function AuthProvider({ children }) {
setUser(null) setUser(null)
localStorage.removeItem('authToken') localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY) localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
clearMediaGrant()
clearCoachSessionStorage() clearCoachSessionStorage()
}, []) }, [])

View File

@ -6,7 +6,6 @@ import {
resolvePlanningModuleDisplayMode, resolvePlanningModuleDisplayMode,
} from '../config/planningModuleUx' } from '../config/planningModuleUx'
import api from '../utils/api' import api from '../utils/api'
import TotpSetupCard from '../components/TotpSetupCard'
/** /**
* Persönliche Einstellungen (Anzeige/Name, Kontostatus, Passwort). * Persönliche Einstellungen (Anzeige/Name, Kontostatus, Passwort).
@ -28,11 +27,6 @@ function AccountSettingsPage() {
PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
) )
const [currentPw, setCurrentPw] = useState('')
const [disablePw, setDisablePw] = useState('')
const [disableCode, setDisableCode] = useState('')
const [disablingTotp, setDisablingTotp] = useState(false)
const [freshRecoveryCodes, setFreshRecoveryCodes] = useState([])
const [newPw1, setNewPw1] = useState('') const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('') const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false) const [savingPw, setSavingPw] = useState(false)
@ -150,8 +144,8 @@ function AccountSettingsPage() {
const handleChangePassword = async (e) => { const handleChangePassword = async (e) => {
e.preventDefault() e.preventDefault()
if (newPw1.length < 8) { if (newPw1.length < 4) {
showErr('Neues Passwort: mindestens 8 Zeichen.') showErr('Neues Passwort: mindestens 4 Zeichen.')
return return
} }
if (newPw1 !== newPw2) { if (newPw1 !== newPw2) {
@ -160,12 +154,10 @@ function AccountSettingsPage() {
} }
setSavingPw(true) setSavingPw(true)
try { try {
const result = await api.changePassword(currentPw, newPw1) await api.changePassword(newPw1)
if (result?.token) localStorage.setItem('authToken', result.token)
setCurrentPw('')
setNewPw1('') setNewPw1('')
setNewPw2('') setNewPw2('')
showOk('Passwort aktualisiert. Andere Geräte sind abgemeldet.') showOk('Passwort aktualisiert.')
} catch (err) { } catch (err) {
showErr(err.message || 'Passwort konnte nicht geändert werden.') showErr(err.message || 'Passwort konnte nicht geändert werden.')
} finally { } finally {
@ -209,90 +201,6 @@ function AccountSettingsPage() {
</div> </div>
)} )}
{user?.totp_enabled ? (
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt.
Die ersten Codes lassen sich nicht erneut anzeigen. Hier entstehen neue, die alten gelten danach nicht mehr.
</p>
{freshRecoveryCodes.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<textarea
readOnly
rows={8}
className="form-input"
value={freshRecoveryCodes.join('\n')}
style={{ fontFamily: 'ui-monospace, monospace', marginBottom: '0.75rem' }}
/>
<button
type="button"
className="btn btn-secondary"
style={{ marginRight: '0.5rem' }}
onClick={() => navigator.clipboard?.writeText(freshRecoveryCodes.join('\n'))}
>
Kopieren
</button>
<button type="button" className="btn btn-primary" onClick={() => setFreshRecoveryCodes([])}>
Ich habe die Codes gespeichert
</button>
</div>
) : null}
<form
onSubmit={async (e) => {
e.preventDefault()
setDisablingTotp(true)
try {
const data = await api.regenerateRecoveryCodes(disablePw, disableCode.trim())
setFreshRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
setDisableCode('')
showOk('Neue Wiederherstellungscodes sind da. Die bisherigen gelten nicht mehr.')
} catch (err) {
showErr(err.message || 'Neue Codes konnten nicht erzeugt werden')
} finally {
setDisablingTotp(false)
}
}}
>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-pw">Aktuelles Passwort</label>
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-code">Aktueller Code aus der App</label>
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
</div>
<button type="submit" className="btn btn-secondary" disabled={disablingTotp} style={{ marginRight: '0.5rem' }}>
{disablingTotp ? 'Bitte warten…' : 'Neue Wiederherstellungscodes'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={disablingTotp}
onClick={async () => {
setDisablingTotp(true)
try {
await api.disableTotp(disablePw, disableCode.trim())
setDisablePw('')
setDisableCode('')
setFreshRecoveryCodes([])
showOk('Zwei-Faktor ist aus. Den Eintrag in der Authenticator-App kannst du löschen.')
await checkAuth()
} catch (err) {
showErr(err.message || 'Abschalten fehlgeschlagen')
} finally {
setDisablingTotp(false)
}
}}
>
Zwei-Faktor ausschalten
</button>
</form>
</div>
) : (
<TotpSetupCard onEnabled={() => checkAuth()} />
)}
<div className="card" style={{ marginBottom: '1rem' }}> <div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Profil</h2> <h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Profil</h2>
<div style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}> <div style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
@ -556,23 +464,9 @@ function AccountSettingsPage() {
<div className="card"> <div className="card">
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2> <h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}> <p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Das aktuelle Passwort wird geprüft. Danach sind andere Sitzungen ungültig. Mindestens 8 Zeichen. Wähle ein neues Passwort (mindestens 4 Zeichen, wie beim Login gewohnt empfehlen wir längere Passwörter).
</p> </p>
<form onSubmit={handleChangePassword}> <form onSubmit={handleChangePassword}>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="settings-pw-current">
Aktuelles Passwort
</label>
<input
id="settings-pw-current"
type="password"
className="form-input"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}> <div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="settings-pw1"> <label className="form-label" htmlFor="settings-pw1">
Neues Passwort Neues Passwort

View File

@ -15,8 +15,6 @@ function LoginPage() {
const [publicClubs, setPublicClubs] = useState([]) const [publicClubs, setPublicClubs] = useState([])
const [requestedClubId, setRequestedClubId] = useState('') const [requestedClubId, setRequestedClubId] = useState('')
const [resending, setResending] = useState(false) const [resending, setResending] = useState(false)
const [challengeToken, setChallengeToken] = useState('')
const [totpCode, setTotpCode] = useState('')
const navigate = useNavigate() const navigate = useNavigate()
const { checkAuth } = useAuth() const { checkAuth } = useAuth()
@ -41,17 +39,6 @@ function LoginPage() {
try { try {
if (mode === 'login') { if (mode === 'login') {
const response = await api.login(email, password) const response = await api.login(email, password)
if (response?.totp_required && response.challenge_token) {
setChallengeToken(response.challenge_token)
setTotpCode('')
return
}
if (response?.totp_enrollment_required && response.token) {
localStorage.setItem('authToken', response.token)
await checkAuth()
navigate('/')
return
}
localStorage.setItem('authToken', response.token) localStorage.setItem('authToken', response.token)
await checkAuth() await checkAuth()
navigate('/') navigate('/')
@ -73,23 +60,6 @@ function LoginPage() {
} }
} }
const handleTotp = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await api.loginTotp(challengeToken, totpCode.trim())
localStorage.setItem('authToken', response.token)
setChallengeToken('')
await checkAuth()
navigate('/')
} catch (err) {
setError(err.message || 'Code nicht akzeptiert')
} finally {
setLoading(false)
}
}
const handleResendVerification = async () => { const handleResendVerification = async () => {
if (!email.trim()) { if (!email.trim()) {
setError('Zuerst die E-Mail-Adresse eintragen.') setError('Zuerst die E-Mail-Adresse eintragen.')
@ -152,49 +122,6 @@ function LoginPage() {
</button> </button>
</div> </div>
{challengeToken ? (
<form onSubmit={handleTotp}>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.45 }}>
Code aus der Authenticator-App oder ein Wiederherstellungscode.
</p>
<div className="form-row">
<label className="form-label" htmlFor="login-totp">
Einmalcode
</label>
<input
id="login-totp"
className="form-input"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
required
/>
</div>
{error ? (
<div style={{ padding: '0.75rem', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '1rem' }}>
{error}
</div>
) : null}
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? 'Laden...' : 'Bestätigen'}
</button>
<button
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: '0.5rem' }}
onClick={() => {
setChallengeToken('')
setTotpCode('')
setError('')
}}
>
Zurück
</button>
</form>
) : null}
{!challengeToken ? (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{mode === 'register' && ( {mode === 'register' && (
<> <>
@ -288,9 +215,8 @@ function LoginPage() {
{loading ? 'Laden...' : mode === 'login' ? 'Anmelden' : 'Registrieren'} {loading ? 'Laden...' : mode === 'login' ? 'Anmelden' : 'Registrieren'}
</button> </button>
</form> </form>
) : null}
{mode === 'login' && !challengeToken && ( {mode === 'login' && (
<div <div
style={{ style={{
marginTop: '1.25rem', marginTop: '1.25rem',

View File

@ -25,7 +25,7 @@ import {
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import { resolveMediaAssetFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl' import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RightsDeclarationDialog from '../components/RightsDeclarationDialog' import RightsDeclarationDialog from '../components/RightsDeclarationDialog'
import ReportContentModal from '../components/ReportContentModal' import ReportContentModal from '../components/ReportContentModal'
import MediaPreviewModal from '../components/MediaPreviewModal' import MediaPreviewModal from '../components/MediaPreviewModal'
@ -159,8 +159,7 @@ function uploaderLabel(it, viewer) {
} }
function MediaThumb({ mediaId, mimeType }) { function MediaThumb({ mediaId, mimeType }) {
const mediaToken = useMediaAccessToken() const url = resolveMediaAssetFileUrl(mediaId)
const url = resolveMediaAssetFileUrl(mediaId, mediaToken)
const mime = (mimeType || '').toLowerCase() const mime = (mimeType || '').toLowerCase()
/* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */ /* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */
if (mime.includes('heic') || mime.includes('heif')) { if (mime.includes('heic') || mime.includes('heif')) {
@ -297,7 +296,6 @@ function JournalBoolField({ label, val, context }) {
} }
export default function MediaLibraryPage() { export default function MediaLibraryPage() {
const mediaToken = useMediaAccessToken()
const { user } = useAuth() const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
@ -1124,7 +1122,7 @@ export default function MediaLibraryPage() {
<MediaPreviewModal <MediaPreviewModal
title={preview.original_filename || `Medium #${preview.id}`} title={preview.original_filename || `Medium #${preview.id}`}
media={{ ...preview, asset_legal_hold_active: preview.legal_hold_active }} media={{ ...preview, asset_legal_hold_active: preview.legal_hold_active }}
fileUrl={resolveMediaAssetFileUrl(preview.id, mediaToken)} fileUrl={resolveMediaAssetFileUrl(preview.id)}
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
onReport={ onReport={
(preview.lifecycle_state || 'active') === 'active' && !preview.legal_hold_active (preview.lifecycle_state || 'active') === 'active' && !preview.legal_hold_active

View File

@ -25,46 +25,6 @@ export async function login(email, password) {
}) })
} }
export async function loginTotp(challengeToken, code) {
return request('/api/auth/login/totp', {
method: 'POST',
body: JSON.stringify({ challenge_token: challengeToken, code }),
})
}
export async function getTotpStatus() {
return request('/api/auth/totp/status')
}
export async function setupTotp() {
return request('/api/auth/totp/setup', { method: 'POST', body: '{}' })
}
export async function confirmTotp(code) {
return request('/api/auth/totp/confirm', {
method: 'POST',
body: JSON.stringify({ code }),
})
}
export async function regenerateRecoveryCodes(currentPassword, code) {
return request('/api/auth/totp/recovery-codes', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, code }),
})
}
export async function disableTotp(currentPassword, code) {
return request('/api/auth/totp/disable', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, code }),
})
}
export async function issueMediaGrant() {
return request('/api/auth/media-grant', { method: 'POST', body: '{}' })
}
export async function register(email, password, name, extra = {}) { export async function register(email, password, name, extra = {}) {
return request('/api/auth/register', { return request('/api/auth/register', {
method: 'POST', method: 'POST',
@ -166,13 +126,10 @@ export async function managementPasswordReset(profileId, newPassword = null) {
}) })
} }
export async function changePassword(currentPassword, newPassword) { export async function changePassword(newPassword) {
return request('/api/auth/pin', { return request('/api/auth/pin', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({ pin: newPassword }),
current_password: currentPassword,
new_password: newPassword,
}),
}) })
} }
@ -1035,13 +992,6 @@ export async function healthCheck() {
export const api = { export const api = {
// Auth // Auth
login, login,
loginTotp,
getTotpStatus,
setupTotp,
confirmTotp,
regenerateRecoveryCodes,
disableTotp,
issueMediaGrant,
register, register,
logout, logout,
getCurrentProfile, getCurrentProfile,

View File

@ -1,77 +1,31 @@
/** URL für Übungs-Mediendateien. Query-Token ist ein kurzlebiges Medien-Grant, kein Sitzungstoken. */ /** URL für Übungs-Mediendateien: API mit Token (Legacy /media/ ohne Auth ist abgeschaltet). */
import { useEffect, useState } from 'react'
import api from './api'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
let grant = { token: '', expiresAt: 0 }
const listeners = new Set()
function notify() {
listeners.forEach((fn) => fn())
}
export function peekMediaAccessToken() {
if (grant.expiresAt > Date.now() + 15_000) return grant.token
return ''
}
export function clearMediaGrant() {
grant = { token: '', expiresAt: 0 }
notify()
}
export async function refreshMediaGrant() {
const data = await api.issueMediaGrant()
const expiresAt = data?.expires_at ? new Date(data.expires_at).getTime() : Date.now() + 9 * 60 * 1000
grant = { token: data?.token || '', expiresAt }
notify()
return grant.token
}
export function useMediaAccessToken() {
const [token, setToken] = useState(peekMediaAccessToken)
useEffect(() => {
const pull = () => setToken(peekMediaAccessToken())
listeners.add(pull)
pull()
if (!peekMediaAccessToken()) {
refreshMediaGrant().catch(() => {})
}
return () => listeners.delete(pull)
}, [])
return token
}
function withGrant(path, accessToken) {
const token = accessToken || peekMediaAccessToken()
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
return `${API_BASE}${path}${q}`
}
/** /**
* @param {number|string} exerciseId * @param {number|string} exerciseId
* @param {object} media — exercise_media Zeile mit id, file_path * @param {object} media — exercise_media Zeile mit id, file_path
* @param {string} [accessToken]
* @returns {string|null} * @returns {string|null}
*/ */
export function resolveExerciseMediaFileUrl(exerciseId, media, accessToken) { export function resolveExerciseMediaFileUrl(exerciseId, media) {
if (!media?.file_path) return null if (!media?.file_path) return null
const fp = String(media.file_path) const fp = String(media.file_path)
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp if (fp.startsWith('http://') || fp.startsWith('https://')) return fp
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
const id = media.id const id = media.id
if (id == null || exerciseId == null) return null if (id == null || exerciseId == null) return null
return withGrant(`/api/exercises/${exerciseId}/media/${id}/file`, accessToken) return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
} }
/** /**
* Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile). * Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
* @param {number|string} assetId — media_assets.id * @param {number|string} assetId — media_assets.id
* @param {string} [accessToken]
* @returns {string|null} * @returns {string|null}
*/ */
export function resolveMediaAssetFileUrl(assetId, accessToken) { export function resolveMediaAssetFileUrl(assetId) {
if (assetId == null) return null if (assetId == null) return null
return withGrant(`/api/media-assets/${assetId}/file`, accessToken) const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
return `${API_BASE}/api/media-assets/${assetId}/file${q}`
} }