Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b719f85c4e | |||
| ea7de64061 | |||
| dbc2dfacb9 | |||
| 9b4d091637 | |||
| 4724da28b1 | |||
| 50c9beb4b3 | |||
| 29a5db63e0 | |||
| 6db31e7312 | |||
| 16187fbbd0 | |||
| 9ba35dc022 | |||
| fc5748bef1 | |||
| 3bf012a8f4 | |||
| 69f238d9b8 | |||
| a1b85cd865 | |||
| 4720d70af0 | |||
| 57c464c9f6 | |||
| d42eb3ac52 | |||
| f9df2d31db | |||
| 1e2fdeeb0f | |||
| 7450c269a5 | |||
| 99a5fccaa5 | |||
| 55d87d8d17 | |||
| b35a5ae216 | |||
| bd9cfaa6e4 | |||
| 639392e133 | |||
| d153a22545 | |||
| 81d1e9bdfd | |||
| 3214055531 | |||
| 4c974620d8 | |||
| 3134160003 | |||
| b19940c997 | |||
| dbc9057601 | |||
| 6136813f60 | |||
| d82b805ffa | |||
| c8c40474d1 | |||
| f035b5bb0b | |||
| 696cb09bf4 | |||
| 33fedd5ec6 | |||
| c8a08f8a94 | |||
| d04ebee1f6 | |||
| 0c1fbab0ef | |||
| a7d68c0646 | |||
| a34dc19f5d | |||
| 9227b98431 | |||
| 390b0ecb73 | |||
| 7e7adfab54 | |||
| bce235b9f6 | |||
| 5334836207 | |||
| 38d78abc3b | |||
| a143532486 | |||
| 2f54fef88e |
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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**)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}` |
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
|
||||||
|
|
@ -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 |
|
|
||||||
|
|
@ -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**.
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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 |
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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%',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user