commit a426c03598b7198232964d0932a120ed8c658e5c Author: Lars Date: Tue Apr 21 14:26:12 2026 +0200 feat: Initial Shinkan setup - Repository structure created - Core backend files from Mitai (auth, db, db_init) - Shinkan-specific: version.py, models.py, main.py - Documentation: CLAUDE.md, README.md - Environment: .env.example, .gitignore version: 0.1.0 date: 2026-04-21 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d3bd6f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Database +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=shinkan +DB_USER=shinkan_user +DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD + +# OpenRouter (KI - optional) +OPENROUTER_API_KEY=your_api_key_here +OPENROUTER_MODEL=anthropic/claude-sonnet-4 + +# SMTP (E-Mail) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=noreply@jinkendo.de +SMTP_PASS=your_smtp_password +SMTP_FROM=noreply@jinkendo.de + +# App +APP_URL=https://shinkan.jinkendo.de +ALLOWED_ORIGINS=https://shinkan.jinkendo.de +ENVIRONMENT=production + +# Media Storage +MEDIA_DIR=/app/media diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06b8805 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Environment +.env +.env.local +.env.production + +# Dependencies +node_modules/ +__pycache__/ +*.pyc +*.pyo +.Python +*.egg-info/ +dist/ +build/ +.venv/ +venv/ + +# Build output +frontend/dist/ + +# Data (NEVER commit database or user data) +*.db +*.sqlite +*.sqlite3 +data/ +photos/ +uploads/ +media/ + +# Logs +*.log +logs/ + +# IDE +.vscode/settings.json +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Docker overrides +docker-compose.override.yml + +# SSL certificates (never commit) +nginx/ssl/ +nginx/certbot/ +*.pem +*.key +*.crt +*.csr + +# Pytest +.pytest_cache/ +.coverage +coverage/ + +# Temp +tmp/ +*.tmp + +# Claude: nur ausgewählte Bereiche versionieren +.claude/** +!.claude/README.md +!.claude/docs/ +!.claude/docs/**/* +!.claude/rules/ +!.claude/rules/**/* +!.claude/commands/ +!.claude/commands/**/* +.claude/settings.local.json + +# Cursor MCP +.cursor/mcp.json +frontend/package-lock.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba9bc75 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,289 @@ +# Shinkan Jinkendo – Entwickler-Kontext für Claude Code + +## Pflicht-Lektüre für Claude Code + +> VOR jeder Implementierung lesen: +> | Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` | +> | Coding-Regeln | `.claude/rules/CODING_RULES.md` | +> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` | +> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` | +> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` | + +## Projekt-Übersicht + +**Shinkan Jinkendo** (真観 Jinkendo) – Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung. +Teil der **Jinkendo**-App-Familie (人拳道). Domains: shinkan.jinkendo.de + +**WICHTIG:** Shinkan ist KEINE persönliche Tracking-App! + +**Fachlicher Fokus:** +- Übungsverwaltung und -suche +- Trainingsplanung für Gruppen +- Fähigkeiten- und Methodenkataloge +- Standardisierung und Wiederverwendung +- Freigabe und Governance von Inhalten + +**Primäre Nutzer:** Trainer, Vereinsadmins, Redakteure +**Nicht in MVP:** Individuelle Sportler, persönliches Tracking + +## Tech-Stack + +| Komponente | Technologie | +|-----------|-------------| +| Frontend | React 18 + Vite + PWA (Node 20) | +| Backend | FastAPI Python 3.12 | +| Datenbank | PostgreSQL 16 Alpine | +| Container | Docker + Docker Compose | +| Auth | Token-basiert + bcrypt | +| KI | OpenRouter API (optional, nicht MVP-kritisch) | + +**Ports:** Prod 3003/8003 · Dev 3098/8098 – nie ändern! + +## Verzeichnisstruktur + +``` +backend/ +├── main.py # App-Setup + Router-Registration +├── db.py # PostgreSQL Connection Pool (von Mitai) +├── db_init.py # DB-Init + Migrations-System (von Mitai) +├── auth.py # Hash, Verify, Sessions (von Mitai) +├── models.py # Pydantic Models +├── version.py # Versionskontrolle +├── migrations/ # SQL-Migrationen (XXX_*.sql Pattern) +└── routers/ # Router-Module + auth · profiles · clubs · groups · skills · methods + exercises · training_units · training_programs + planning · import_wiki · admin · membership + +frontend/src/ +├── App.jsx # Root, Auth-Gates, Navigation +├── app.css # CSS-Variablen + globale Styles +├── config/ # appNav.js · adminNav.js +├── layouts/ # AdminShell · RequireAdmin +├── context/ # AuthContext · ProfileContext +├── pages/ # Alle Screens +└── utils/ + api.js # ALLE API-Calls + +.claude/ +├── commands/ # Slash-Commands +├── docs/ +│ ├── functional/ # Fachliche Specs +│ ├── technical/ # Technische Specs +│ └── rules/ # Verbindliche Regeln +└── library/ # Auto-generierte Docs +``` + +## Aktuelle Version: v0.1.0 (Initial Setup) + +**Status:** Initial Setup in Arbeit +**Branch:** develop +**Nächster Schritt:** Basis-Migrationen + Core-Router + +### Updates (21.04.2026 - Initial Setup) + +- **Repository:** Erstellt auf Gitea +- **Basis-Struktur:** Verzeichnisse angelegt +- **Von Mitai übernommen:** auth.py, db.py, db_init.py +- **Eigene Dateien:** version.py, CLAUDE.md + +## Domänenmodell (MVP Core) + +### Kern-Objekte + +**Organisation:** +- `clubs` - Vereine +- `divisions` - Sparten (optional) +- `training_groups` - Trainingsgruppen + +**Kataloge:** +- `skills` - Fähigkeiten (global) +- `training_methods` - Trainingsmethoden (global) + +**Übungen:** +- `exercises` - Übungen (Kernobjekt) +- `exercise_variants` - Übungsvarianten +- `exercise_skills` - M:N Übung ↔ Fähigkeit +- `exercise_media` - Medien (Bilder, Videos) + +**Trainingsplanung:** +- `training_templates` - Vorlagen / Standards +- `training_sections` - Trainingsabschnitte +- `section_exercises` - Übungen in Abschnitten +- `training_units` - Konkrete Trainingseinheiten +- `training_programs` - Trainingsprogramme + +**Governance:** +- `content_change_requests` - Änderungsanfragen + +**Import:** +- `wiki_import_log` - Import-Tracking +- `wiki_import_references` - Duplikat-Erkennung + +## Deployment + +``` +Internet → Fritz!Box (privat.stommer.com) → Synology NAS → Raspberry Pi 5 (192.168.2.49) + +Git Workflow: + develop → Auto-Deploy → dev.shinkan.jinkendo.de (shinkan-dev/, Port 3098/8098) + main → Auto-Deploy → shinkan.jinkendo.de (shinkan/, Port 3003/8003) + +Gitea: http://192.168.2.144:3000/Lars/shinkan-jinkendo +Runner: Raspberry Pi (/home/lars/gitea-runner/) + +Manuell: + cd /home/lars/docker/shinkan[-dev] + docker compose -f docker-compose[.dev-env].yml build --no-cache && up -d + +Migrations: + Werden automatisch beim Container-Start ausgeführt (db_init.py) + Nur nummerierte Dateien: backend/migrations/XXX_*.sql +``` + +## Datenbank-Schema (PostgreSQL 16) + +``` +-- Von Mitai übernommen +profiles – Nutzer (role, pin_hash/bcrypt, email, tier) +sessions – Auth-Tokens +features – Feature-Definitionen +tier_limits – Limits pro Tier +subscriptions – Nutzer-Subscriptions + +-- Shinkan-spezifisch +clubs – Vereine +divisions – Sparten +training_groups – Trainingsgruppen +skills – Fähigkeiten-Katalog +training_methods – Methoden-Katalog +exercises – Übungen (Kernobjekt) +exercise_variants – Übungsvarianten +exercise_skills – M:N Übung ↔ Fähigkeit +exercise_media – Medien +training_templates – Vorlagen / Standards +training_sections – Trainingsabschnitte +section_exercises – Übungen in Abschnitten +training_units – Trainingseinheiten +training_programs – Trainingsprogramme +program_units – Programm-Einheiten +content_change_requests – Änderungsanfragen +wiki_import_log – Import-Tracking +wiki_import_references – Duplikat-Erkennung + +Schema-Datei: backend/schema.sql (später) +Migrationen: backend/migrations/*.sql (automatisch beim Start) +``` + +## API & Auth + +``` +Alle Endpoints: /api/... +Auth-Header: X-Auth-Token: +Fehler: {"detail": "Fehlermeldung"} + +Auth-Flow: + Login → E-Mail + Passwort → Token in localStorage + Token → X-Auth-Token Header → require_auth() + Profile-Id → immer aus Session, nie aus Header! +``` + +## Umgebungsvariablen (.env) + +``` +DB_HOST/PORT/NAME/USER/PASSWORD # PostgreSQL +OPENROUTER_API_KEY # KI (optional) +OPENROUTER_MODEL=anthropic/claude-sonnet-4 +SMTP_HOST/PORT/USER/PASS/FROM # E-Mail +APP_URL=https://shinkan.jinkendo.de +ALLOWED_ORIGINS=https://shinkan.jinkendo.de +MEDIA_DIR=/app/media +``` + +## Kritische Regeln für Claude Code + +### Must-Do: +1. `api.js` für ALLE API-Calls nutzen – nie direktes `fetch()` ohne Token +2. `session: dict = Depends(require_auth)` als **separater** Parameter +3. `bcrypt` für alle Passwort-Operationen +4. Neue DB-Spalten nur via Schema-Migration +5. `npm install` (nicht npm ci) – kein package-lock.json + +### Bekannte Fallstricke: +```python +# ❌ FALSCH – führt zu ungeschütztem Endpoint: +def endpoint(x: str = Header(default=None, session=Depends(require_auth))): + +# ✅ RICHTIG: +def endpoint(x: str = Header(default=None), session: dict = Depends(require_auth)): +``` + +```python +# PostgreSQL Boolean (nicht SQLite 0/1): +WHERE active = true # ✅ +WHERE active = 1 # ❌ +``` + +## Design-System (Kurzreferenz) + +```css +/* Farben */ +--accent: #1D9E75 --accent-dark: #085041 --danger: #D85A30 +--bg · --surface · --surface2 · --border · --text1 · --text2 · --text3 + +/* Klassen */ +.card · .btn · .btn-primary · .btn-secondary · .btn-full +.form-input · .form-label · .form-row · .spinner + +/* Abstände */ +Seiten-Padding: 16px · Card-Padding: 16-20px · Border-Radius: 12px/8px +Bottom-Padding Mobile: 80px (Navigation) +``` + +## Dokumentations-Struktur + +``` +.claude/ +├── docs/ +│ ├── functional/ ← Fachliche Specs (WAS soll gebaut werden) +│ ├── technical/ ← Technische Specs (WIE wird es gebaut) +│ ├── working/ ← Arbeitspapiere / Analysen +│ └── rules/ ← Verbindliche Regeln +└── library/ ← Ergebnis-Dokumentation (WAS wurde gebaut) +``` + +## Abgrenzung zu Mitai + +**Mitai (身体):** +- Persönliches Körper- und Trainings-Tracking +- Gewicht, Umfänge, Ernährung, Aktivität +- Individuelle Ziele und Fortschritte +- KI-Analysen für persönliche Entwicklung + +**Shinkan (真観):** +- Trainer- und Vereinsarbeit +- Übungsverwaltung und -suche +- Trainingsplanung für Gruppen +- Fähigkeiten- und Methodenkataloge +- Standardisierung und Wiederverwendung + +**Technisch gemeinsam:** +- Auth-System +- Membership-Basis +- Design-System +- Docker/Deployment-Infrastruktur + +## Jinkendo App-Familie + +``` +mitai.jinkendo.de → Körper-Tracker (身体) +shinkan.jinkendo.de → Trainingsplanung (真観) +miken.jinkendo.de → Meditation (眉間) +ikigai.jinkendo.de → Lebenssinn (生き甲斐) +``` + +--- + +**Version:** 0.1.0 +**Stand:** 21.04.2026 +**Autor:** Claude Code diff --git a/README.md b/README.md new file mode 100644 index 0000000..f49ed9d --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Shinkan Jinkendo (真観) + +**Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung** + +Teil der Jinkendo-App-Familie (人拳道) + +## Was ist Shinkan? + +Shinkan ist eine moderne Web- und Mobile-App für Kampfsport-Trainer und Vereine. Im Fokus stehen: + +- **Übungsverwaltung:** Zentrale Übungsbibliothek mit Suche und Filter +- **Trainingsplanung:** Effiziente Planung für Gruppen und Termine +- **Kataloge:** Fähigkeiten und Trainingsmethoden strukturiert verwalten +- **Standardisierung:** Vereinsstandards und wiederverwendbare Vorlagen +- **Freigabe:** Gesteuerte Veröffentlichung von Inhalten + +## Nicht in Shinkan + +- Kein persönliches Sportler-Tracking (dafür: Mitai Jinkendo) +- Kein Gürtel-Tracking im MVP +- Keine individuellen Technik-Fortschritte im MVP + +## Tech-Stack + +- **Frontend:** React 18 + Vite + PWA +- **Backend:** FastAPI (Python 3.12) +- **Datenbank:** PostgreSQL 16 +- **Container:** Docker + Docker Compose +- **Auth:** Token-basiert + bcrypt + +## Installation (Lokal) + +### Voraussetzungen +- Node 20+ +- Python 3.12+ +- PostgreSQL 16 +- Docker + Docker Compose + +### Setup + +```bash +# Repository clonen +git clone http://192.168.2.144:3000/Lars/shinkan-jinkendo.git +cd shinkan-jinkendo + +# Environment-Variablen +cp .env.example .env +# .env anpassen! + +# Backend +cd backend +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt + +# Frontend +cd ../frontend +npm install + +# Datenbank +docker compose up -d postgres + +# Migrations (automatisch beim Start) +cd ../backend +python main.py +``` + +### Development + +```bash +# Backend (Terminal 1) +cd backend +source venv/bin/activate +uvicorn main:app --reload --port 8098 + +# Frontend (Terminal 2) +cd frontend +npm run dev +``` + +Frontend: http://localhost:3098 +Backend: http://localhost:8098 + +### Docker (Empfohlen) + +```bash +# Development +docker compose -f docker-compose.dev-env.yml up --build + +# Production +docker compose up --build +``` + +## Deployment + +**Production:** https://shinkan.jinkendo.de +**Development:** https://dev.shinkan.jinkendo.de + +Auto-Deploy via Gitea Actions: +- `develop` → Dev-Umgebung +- `main` → Prod-Umgebung + +## Dokumentation + +- **Setup:** `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` +- **Anforderungen:** `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` +- **Architektur:** `.claude/rules/ARCHITECTURE.md` + +## Lizenz + +Proprietary – Lars Stommer + +## Kontakt + +- **Entwickler:** Lars Stommer +- **E-Mail:** stommer@gmail.com +- **Gitea:** http://192.168.2.144:3000/Lars/shinkan-jinkendo diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..f255879 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,379 @@ +""" +Authentication and Authorization for Mitai Jinkendo + +Provides password hashing, session management, and auth dependencies +for FastAPI endpoints. +""" +import hashlib +import secrets +from typing import Optional +from datetime import datetime, timedelta +from fastapi import Header, Query, HTTPException +import bcrypt + +from db import get_db, get_cursor + +print("[AUTH.PY] Module loaded - require_auth_flexible will be defined") + + +def hash_pin(pin: str) -> str: + """Hash password with bcrypt. Falls back gracefully from legacy SHA256.""" + return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() + + +def verify_pin(pin: str, stored_hash: str) -> bool: + """Verify password - supports both bcrypt and legacy SHA256.""" + if not stored_hash: + return False + # Detect bcrypt hash (starts with $2b$ or $2a$) + if stored_hash.startswith('$2'): + try: + return bcrypt.checkpw(pin.encode(), stored_hash.encode()) + except Exception: + return False + # Legacy SHA256 support (auto-upgrade to bcrypt on next login) + return stored_hash == hashlib.sha256(pin.encode()).hexdigest() + + +def make_token() -> str: + """Generate a secure random token for sessions.""" + return secrets.token_urlsafe(32) + + +def get_session(token: str): + """ + Get session data for a given token. + + Returns session dict with profile info, or None if invalid/expired. + """ + if not token: + return None + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT s.*, p.role, p.name, p.ai_enabled, p.ai_limit_day, p.export_enabled " + "FROM sessions s JOIN profiles p ON s.profile_id=p.id " + "WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP", + (token,) + ) + return cur.fetchone() + + +def require_auth(x_auth_token: Optional[str] = Header(default=None)): + """ + FastAPI dependency - requires valid authentication. + + Usage: + @app.get("/api/endpoint") + def endpoint(session: dict = Depends(require_auth)): + profile_id = session['profile_id'] + ... + + Raises: + HTTPException 401 if not authenticated + """ + session = get_session(x_auth_token) + if not session: + raise HTTPException(401, "Nicht eingeloggt") + return session + + +def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ssetoken: Optional[str] = Query(default=None)): + """ + FastAPI dependency - auth via header OR query parameter. + + Used for endpoints accessed by tags and SSE connections that can't send headers. + Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters. + + Usage: + @app.get("/api/photos/{id}") + def get_photo(id: str, session: dict = Depends(require_auth_flexible)): + ... + + Call with: ?ssetoken=XXX or Header: X-Auth-Token: XXX + + Raises: + HTTPException 401 if not authenticated + """ + session = get_session(x_auth_token or ssetoken) + if not session: + raise HTTPException(401, "Nicht eingeloggt") + return session + + +def require_admin(x_auth_token: Optional[str] = Header(default=None)): + """ + FastAPI dependency - requires admin authentication. + + Usage: + @app.put("/api/admin/endpoint") + def admin_endpoint(session: dict = Depends(require_admin)): + ... + + Raises: + HTTPException 401 if not authenticated + HTTPException 403 if not admin + """ + session = get_session(x_auth_token) + if not session: + raise HTTPException(401, "Nicht eingeloggt") + if session['role'] != 'admin': + raise HTTPException(403, "Nur für Admins") + return session + + +# ============================================================================ +# Feature Access Control (v9c) +# ============================================================================ + +def get_effective_tier(profile_id: str, conn=None) -> str: + """ + Get the effective tier for a profile. + + Checks for active access_grants first (from coupons, trials, etc.), + then falls back to profile.tier. + + Args: + profile_id: User profile ID + conn: Optional existing DB connection (to avoid pool exhaustion) + + Returns: + tier_id (str): 'free', 'basic', 'premium', or 'selfhosted' + """ + # Use existing connection if provided, otherwise open new one + if conn: + cur = get_cursor(conn) + + # Check for active access grants (highest priority) + cur.execute(""" + SELECT tier_id + FROM access_grants + WHERE profile_id = %s + AND is_active = true + AND valid_from <= CURRENT_TIMESTAMP + AND valid_until > CURRENT_TIMESTAMP + ORDER BY valid_until DESC + LIMIT 1 + """, (profile_id,)) + + grant = cur.fetchone() + if grant: + return grant['tier_id'] + + # Fall back to profile tier + cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,)) + profile = cur.fetchone() + return profile['tier'] if profile else 'free' + else: + # Open new connection if none provided + with get_db() as conn: + return get_effective_tier(profile_id, conn) + + +def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict: + """ + Check if a profile has access to a feature. + + Access hierarchy: + 1. User-specific restriction (user_feature_restrictions) + 2. Tier limit (tier_limits) + 3. Feature default (features.default_limit) + + Args: + profile_id: User profile ID + feature_id: Feature ID to check + conn: Optional existing DB connection (to avoid pool exhaustion) + + Returns: + dict: { + 'allowed': bool, + 'limit': int | None, # NULL = unlimited + 'used': int, + 'remaining': int | None, # NULL = unlimited + 'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled' + } + """ + # Use existing connection if provided + if conn: + return _check_impl(profile_id, feature_id, conn) + else: + with get_db() as conn: + return _check_impl(profile_id, feature_id, conn) + + +def _check_impl(profile_id: str, feature_id: str, conn) -> dict: + """Internal implementation of check_feature_access.""" + cur = get_cursor(conn) + + # Get feature info + cur.execute(""" + SELECT limit_type, reset_period, default_limit + FROM features + WHERE id = %s AND active = true + """, (feature_id,)) + feature = cur.fetchone() + + if not feature: + return { + 'allowed': False, + 'limit': None, + 'used': 0, + 'remaining': None, + 'reason': 'feature_not_found' + } + + # Priority 1: Check user-specific restriction + cur.execute(""" + SELECT limit_value + FROM user_feature_restrictions + WHERE profile_id = %s AND feature_id = %s + """, (profile_id, feature_id)) + restriction = cur.fetchone() + + if restriction is not None: + limit = restriction['limit_value'] + else: + # Priority 2: Check tier limit + tier_id = get_effective_tier(profile_id, conn) + cur.execute(""" + SELECT limit_value + FROM tier_limits + WHERE tier_id = %s AND feature_id = %s + """, (tier_id, feature_id)) + tier_limit = cur.fetchone() + + if tier_limit is not None: + limit = tier_limit['limit_value'] + else: + # Priority 3: Feature default + limit = feature['default_limit'] + + # For boolean features (limit 0 = disabled, 1 = enabled) + if feature['limit_type'] == 'boolean': + allowed = limit == 1 + return { + 'allowed': allowed, + 'limit': limit, + 'used': 0, + 'remaining': None, + 'reason': 'enabled' if allowed else 'feature_disabled' + } + + # For count-based features + # Check current usage + cur.execute(""" + SELECT usage_count, reset_at + FROM user_feature_usage + WHERE profile_id = %s AND feature_id = %s + """, (profile_id, feature_id)) + usage = cur.fetchone() + + used = usage['usage_count'] if usage else 0 + + # Check if reset is needed + if usage and usage['reset_at'] and datetime.now() > usage['reset_at']: + # Reset usage + used = 0 + next_reset = _calculate_next_reset(feature['reset_period']) + cur.execute(""" + UPDATE user_feature_usage + SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP + WHERE profile_id = %s AND feature_id = %s + """, (next_reset, profile_id, feature_id)) + conn.commit() + + # NULL limit = unlimited + if limit is None: + return { + 'allowed': True, + 'limit': None, + 'used': used, + 'remaining': None, + 'reason': 'unlimited' + } + + # 0 limit = disabled + if limit == 0: + return { + 'allowed': False, + 'limit': 0, + 'used': used, + 'remaining': 0, + 'reason': 'feature_disabled' + } + + # Check if within limit + allowed = used < limit + remaining = limit - used if limit else None + + return { + 'allowed': allowed, + 'limit': limit, + 'used': used, + 'remaining': remaining, + 'reason': 'within_limit' if allowed else 'limit_exceeded' + } + + +def increment_feature_usage(profile_id: str, feature_id: str) -> None: + """ + Increment usage counter for a feature. + + Creates usage record if it doesn't exist, with reset_at based on + feature's reset_period. + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Get feature reset period + cur.execute(""" + SELECT reset_period + FROM features + WHERE id = %s + """, (feature_id,)) + feature = cur.fetchone() + + if not feature: + return + + reset_period = feature['reset_period'] + next_reset = _calculate_next_reset(reset_period) + + # Upsert usage + cur.execute(""" + INSERT INTO user_feature_usage (profile_id, feature_id, usage_count, reset_at) + VALUES (%s, %s, 1, %s) + ON CONFLICT (profile_id, feature_id) + DO UPDATE SET + usage_count = user_feature_usage.usage_count + 1, + updated = CURRENT_TIMESTAMP + """, (profile_id, feature_id, next_reset)) + + conn.commit() + + +def _calculate_next_reset(reset_period: str) -> Optional[datetime]: + """ + Calculate next reset timestamp based on reset period. + + Args: + reset_period: 'never', 'daily', 'monthly' + + Returns: + datetime or None (for 'never') + """ + if reset_period == 'never': + return None + elif reset_period == 'daily': + # Reset at midnight + tomorrow = datetime.now().date() + timedelta(days=1) + return datetime.combine(tomorrow, datetime.min.time()) + elif reset_period == 'monthly': + # Reset at start of next month + now = datetime.now() + if now.month == 12: + return datetime(now.year + 1, 1, 1) + else: + return datetime(now.year, now.month + 1, 1) + else: + return None diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..0b07348 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,197 @@ +""" +PostgreSQL Database Connector for Mitai Jinkendo (v9b) + +Provides connection pooling and helper functions for database operations. +Compatible drop-in replacement for the previous SQLite get_db() pattern. +""" +import os +from contextlib import contextmanager +from typing import Optional, Dict, Any, List +import psycopg2 +from psycopg2.extras import RealDictCursor +import psycopg2.pool + + +# Global connection pool +_pool: Optional[psycopg2.pool.SimpleConnectionPool] = None + + +def init_pool(): + """Initialize PostgreSQL connection pool.""" + global _pool + if _pool is None: + _pool = psycopg2.pool.SimpleConnectionPool( + minconn=1, + maxconn=10, + host=os.getenv("DB_HOST", "postgres"), + port=int(os.getenv("DB_PORT", "5432")), + database=os.getenv("DB_NAME", "mitai"), + user=os.getenv("DB_USER", "mitai"), + password=os.getenv("DB_PASSWORD", "") + ) + print( + f"[OK] PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})" + ) + + +@contextmanager +def get_db(): + """ + Context manager for database connections. + + Usage: + with get_db() as conn: + cur = conn.cursor() + cur.execute("SELECT * FROM profiles") + rows = cur.fetchall() + + Auto-commits on success, auto-rolls back on exception. + """ + if _pool is None: + init_pool() + + conn = _pool.getconn() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + _pool.putconn(conn) + + +def get_cursor(conn): + """ + Get cursor with RealDictCursor for dict-like row access. + + Returns rows as dictionaries: {'column_name': value, ...} + Compatible with previous sqlite3.Row behavior. + """ + return conn.cursor(cursor_factory=RealDictCursor) + + +def r2d(row) -> Optional[Dict[str, Any]]: + """ + Convert row to dict (compatibility helper). + + Args: + row: RealDictRow from psycopg2 + + Returns: + Dictionary or None if row is None + """ + return dict(row) if row else None + + +def execute_one(conn, query: str, params: tuple = ()) -> Optional[Dict[str, Any]]: + """ + Execute query and return one row as dict. + + Args: + conn: Database connection from get_db() + query: SQL query with %s placeholders + params: Tuple of parameters + + Returns: + Dictionary with column:value pairs, or None if no row found + + Example: + profile = execute_one(conn, "SELECT * FROM profiles WHERE id=%s", (pid,)) + if profile: + print(profile['name']) + """ + with get_cursor(conn) as cur: + cur.execute(query, params) + row = cur.fetchone() + return r2d(row) + + +def execute_all(conn, query: str, params: tuple = ()) -> List[Dict[str, Any]]: + """ + Execute query and return all rows as list of dicts. + + Args: + conn: Database connection from get_db() + query: SQL query with %s placeholders + params: Tuple of parameters + + Returns: + List of dictionaries (one per row) + + Example: + weights = execute_all(conn, + "SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC", + (pid,) + ) + for w in weights: + print(w['date'], w['weight']) + """ + with get_cursor(conn) as cur: + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +def execute_write(conn, query: str, params: tuple = ()) -> None: + """ + Execute INSERT/UPDATE/DELETE query. + + Args: + conn: Database connection from get_db() + query: SQL query with %s placeholders + params: Tuple of parameters + + Example: + execute_write(conn, + "UPDATE profiles SET name=%s WHERE id=%s", + ("New Name", pid) + ) + """ + with get_cursor(conn) as cur: + cur.execute(query, params) + + +def init_db(): + """ + Initialize database with required data. + + Ensures critical data exists (e.g., pipeline master prompt). + Safe to call multiple times - checks before inserting. + Called automatically on app startup. + """ + try: + with get_db() as conn: + cur = get_cursor(conn) + + # Check if table exists first + cur.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'ai_prompts' + ) as table_exists + """) + if not cur.fetchone()['table_exists']: + print("[WARN] ai_prompts table doesn't exist yet - skipping pipeline prompt creation") + return + + # Ensure "pipeline" master prompt exists + cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'") + if cur.fetchone()['count'] == 0: + cur.execute(""" + INSERT INTO ai_prompts (slug, name, description, template, active, sort_order) + VALUES ( + 'pipeline', + 'Mehrstufige Gesamtanalyse', + 'Master-Schalter für die gesamte Pipeline. Deaktiviere diese Analyse, um die Pipeline komplett zu verstecken.', + 'PIPELINE_MASTER', + true, + -10 + ) + """) + conn.commit() + print("[OK] Pipeline master prompt created") + except Exception as e: + print(f"[WARN] Could not create pipeline prompt: {e}") + # Don't fail startup - prompt can be created manually diff --git a/backend/db_init.py b/backend/db_init.py new file mode 100644 index 0000000..6714613 --- /dev/null +++ b/backend/db_init.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Database initialization script for PostgreSQL. +Replaces psql commands in startup.sh with pure Python. +""" +import os +import sys +import time +import psycopg2 +from psycopg2 import OperationalError + +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "mitai_dev") +DB_USER = os.getenv("DB_USER", "mitai_dev") +DB_PASSWORD = os.getenv("DB_PASSWORD", "") + +def get_connection(): + """Get PostgreSQL connection.""" + return psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + +def wait_for_postgres(max_retries=30): + """Wait for PostgreSQL to be ready.""" + print("\nChecking PostgreSQL connection...") + for i in range(1, max_retries + 1): + try: + conn = get_connection() + conn.close() + print("✓ PostgreSQL ready") + return True + except OperationalError: + print(f" Waiting for PostgreSQL... (attempt {i}/{max_retries})") + time.sleep(2) + + print(f"✗ PostgreSQL not ready after {max_retries} attempts") + return False + +def check_table_exists(table_name="profiles"): + """Check if a table exists.""" + try: + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema='public' AND table_name=%s + """, (table_name,)) + count = cur.fetchone()[0] + cur.close() + conn.close() + return count > 0 + except Exception as e: + print(f"Error checking table: {e}") + return False + +def load_schema(schema_file="/app/schema.sql"): + """Load schema from SQL file.""" + try: + with open(schema_file, 'r') as f: + schema_sql = f.read() + + conn = get_connection() + cur = conn.cursor() + cur.execute(schema_sql) + conn.commit() + cur.close() + conn.close() + print("✓ Schema loaded from schema.sql") + return True + except Exception as e: + print(f"✗ Error loading schema: {e}") + return False + +def get_profile_count(): + """Get number of profiles in database.""" + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM profiles") + count = cur.fetchone()[0] + cur.close() + conn.close() + return count + except Exception as e: + print(f"Error getting profile count: {e}") + return -1 + +def ensure_migration_table(): + """Create migration tracking table if it doesn't exist.""" + try: + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS schema_migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(255) UNIQUE NOT NULL, + applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() + cur.close() + conn.close() + return True + except Exception as e: + print(f"Error creating migration table: {e}") + return False + +def get_applied_migrations(): + """Get list of already applied migrations.""" + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT filename FROM schema_migrations ORDER BY filename") + migrations = [row[0] for row in cur.fetchall()] + cur.close() + conn.close() + return migrations + except Exception as e: + print(f"Error getting applied migrations: {e}") + return [] + +def apply_migration(filepath, filename): + """Apply a single migration file.""" + try: + with open(filepath, 'r') as f: + migration_sql = f.read() + + conn = get_connection() + cur = conn.cursor() + + # Execute migration + cur.execute(migration_sql) + + # Record migration + cur.execute( + "INSERT INTO schema_migrations (filename) VALUES (%s)", + (filename,) + ) + + conn.commit() + cur.close() + conn.close() + print(f" ✓ Applied: {filename}") + return True + except Exception as e: + print(f" ✗ Failed to apply {filename}: {e}") + return False + +def run_migrations(migrations_dir="/app/migrations"): + """Run all pending migrations.""" + import glob + import re + + if not os.path.exists(migrations_dir): + print("✓ No migrations directory found") + return True + + # Ensure migration tracking table exists + if not ensure_migration_table(): + return False + + # Get already applied migrations + applied = get_applied_migrations() + + # Get all migration files (only numbered migrations like 001_*.sql) + all_files = sorted(glob.glob(os.path.join(migrations_dir, "*.sql"))) + migration_pattern = re.compile(r'^\d{3}_.*\.sql$') + migration_files = [f for f in all_files if migration_pattern.match(os.path.basename(f))] + + if not migration_files: + print("✓ No migration files found") + return True + + # Apply pending migrations + pending = [] + for filepath in migration_files: + filename = os.path.basename(filepath) + if filename not in applied: + pending.append((filepath, filename)) + + if not pending: + print(f"✓ All {len(applied)} migrations already applied") + return True + + print(f" Found {len(pending)} pending migration(s)...") + for filepath, filename in pending: + if not apply_migration(filepath, filename): + return False + + return True + +if __name__ == "__main__": + print("═══════════════════════════════════════════════════════════") + print("MITAI JINKENDO - Database Initialization (v9c)") + print("═══════════════════════════════════════════════════════════") + + # Wait for PostgreSQL + if not wait_for_postgres(): + sys.exit(1) + + # Check schema + print("\nChecking database schema...") + if not check_table_exists("profiles"): + print(" Schema not found, initializing...") + if not load_schema(): + sys.exit(1) + else: + print("✓ Schema already exists") + + # Run migrations + print("\nRunning database migrations...") + if not run_migrations(): + print("✗ Migration failed") + sys.exit(1) + + # Check for migration + print("\nChecking for SQLite data migration...") + sqlite_db = "/app/data/bodytrack.db" + profile_count = get_profile_count() + + if os.path.exists(sqlite_db) and profile_count == 0: + print(" SQLite database found and PostgreSQL is empty") + print(" Starting automatic migration...") + # Import and run migration + try: + from migrate_to_postgres import main as migrate + migrate() + except Exception as e: + print(f"✗ Migration failed: {e}") + sys.exit(1) + elif os.path.exists(sqlite_db) and profile_count > 0: + print(f"⚠ SQLite DB exists but PostgreSQL already has {profile_count} profiles") + print(" Skipping migration (already migrated)") + elif not os.path.exists(sqlite_db): + print("✓ No SQLite database found (fresh install or already migrated)") + else: + print("✓ No migration needed") + + print("\n✓ Database initialization complete") diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..3628b71 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,78 @@ +""" +Shinkan Jinkendo - Main Application Entry Point + +Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung +""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +import os + +from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS +from db_init import init_db + +# Initialize FastAPI app +app = FastAPI( + title="Shinkan Jinkendo API", + description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung", + version=APP_VERSION +) + +# CORS Configuration +ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3098").split(",") + +app.add_middleware( + CORSMiddleware, + allow_origins=ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Initialize Database (runs migrations automatically) +init_db() + +# Version Endpoint (public, no auth) +@app.get("/api/version") +def get_version(): + """Get application version and build info""" + return { + "app_version": APP_VERSION, + "build_date": BUILD_DATE, + "backend_version": APP_VERSION, + "modules": MODULE_VERSIONS, + "db_schema_version": DB_SCHEMA_VERSION, + "environment": os.getenv("ENVIRONMENT", "development") + } + +# Health Check +@app.get("/health") +def health_check(): + """Health check endpoint""" + return {"status": "healthy", "version": APP_VERSION} + +# Root Endpoint +@app.get("/") +def read_root(): + """Root endpoint - API info""" + return { + "app": "Shinkan Jinkendo API", + "version": APP_VERSION, + "docs": "/docs", + "health": "/health" + } + +# TODO: Register routers here as they are created +# from routers import auth, profiles, clubs, groups, skills, methods, exercises +# app.include_router(auth.router, prefix="/api") +# app.include_router(profiles.router, prefix="/api") +# ... etc + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True + ) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..a5e4203 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,225 @@ +""" +Pydantic Models for Shinkan Jinkendo API + +Request/Response schemas for all endpoints +""" +from pydantic import BaseModel, EmailStr, Field +from typing import Optional, List +from datetime import date, time, datetime + +# ============================================================================ +# Auth & Profiles (von Mitai übernommen) +# ============================================================================ + +class LoginRequest(BaseModel): + email: EmailStr + password: str + +class RegisterRequest(BaseModel): + email: EmailStr + password: str + name: Optional[str] = None + +class ProfileResponse(BaseModel): + id: int + email: str + name: Optional[str] + role: str + tier: str + email_verified: bool + created_at: datetime + +# ============================================================================ +# Clubs & Groups +# ============================================================================ + +class ClubCreate(BaseModel): + name: str + abbreviation: Optional[str] = None + description: Optional[str] = None + +class ClubResponse(BaseModel): + id: int + name: str + abbreviation: Optional[str] + description: Optional[str] + status: str + created_at: datetime + +class TrainingGroupCreate(BaseModel): + club_id: int + division_id: Optional[int] = None + name: str + focus: Optional[str] = None + level: Optional[str] = None + age_group: Optional[str] = None + weekday: Optional[str] = None + time_start: Optional[time] = None + time_end: Optional[time] = None + location: Optional[str] = None + trainer_id: Optional[int] = None + co_trainer_ids: Optional[List[int]] = [] + +class TrainingGroupResponse(BaseModel): + id: int + club_id: int + name: str + focus: Optional[str] + level: Optional[str] + age_group: Optional[str] + weekday: Optional[str] + time_start: Optional[time] + time_end: Optional[time] + location: Optional[str] + trainer_id: Optional[int] + status: str + created_at: datetime + +# ============================================================================ +# Skills & Methods +# ============================================================================ + +class SkillCreate(BaseModel): + name: str + category: Optional[str] = None + description: Optional[str] = None + importance: Optional[int] = Field(None, ge=1, le=5) + keywords: Optional[List[str]] = [] + +class SkillResponse(BaseModel): + id: int + name: str + category: Optional[str] + description: Optional[str] + importance: Optional[int] + keywords: Optional[List[str]] + status: str + created_at: datetime + +class MethodCreate(BaseModel): + name: str + abbreviation: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + typical_duration: Optional[int] = None + typical_group_size: Optional[str] = None + related_skills: Optional[List[int]] = [] + keywords: Optional[List[str]] = [] + +class MethodResponse(BaseModel): + id: int + name: str + abbreviation: Optional[str] + category: Optional[str] + description: Optional[str] + typical_duration: Optional[int] + typical_group_size: Optional[str] + related_skills: Optional[List[int]] + keywords: Optional[List[str]] + status: str + created_at: datetime + +# ============================================================================ +# Exercises (Kernobjekt) +# ============================================================================ + +class ExerciseCreate(BaseModel): + title: str + summary: Optional[str] = None + goal: str + execution: str + preparation: Optional[str] = None + trainer_notes: Optional[str] = None + equipment: Optional[List[str]] = [] + duration_min: Optional[int] = None + duration_max: Optional[int] = None + group_size_min: Optional[int] = None + group_size_max: Optional[int] = None + age_groups: Optional[List[str]] = [] + focus_area: Optional[str] = None + secondary_areas: Optional[List[str]] = [] + training_character: Optional[str] = None + primary_method_id: Optional[int] = None + secondary_method_ids: Optional[List[int]] = [] + visibility: Optional[str] = "private" + club_id: Optional[int] = None + +class ExerciseResponse(BaseModel): + id: int + title: str + summary: Optional[str] + goal: str + execution: str + preparation: Optional[str] + trainer_notes: Optional[str] + equipment: Optional[List[str]] + duration_min: Optional[int] + duration_max: Optional[int] + group_size_min: Optional[int] + group_size_max: Optional[int] + age_groups: Optional[List[str]] + focus_area: Optional[str] + secondary_areas: Optional[List[str]] + training_character: Optional[str] + primary_method_id: Optional[int] + secondary_method_ids: Optional[List[int]] + visibility: str + status: str + created_by: int + club_id: Optional[int] + created_at: datetime + updated_at: datetime + +class ExerciseSkillCreate(BaseModel): + exercise_id: int + skill_id: int + is_primary: bool = False + intensity: Optional[int] = Field(None, ge=1, le=5) + development_contribution: Optional[str] = None + required_level: Optional[int] = None + target_level: Optional[int] = None + +# ============================================================================ +# Training Planning +# ============================================================================ + +class TrainingUnitCreate(BaseModel): + group_id: int + date: date + time_start: Optional[time] = None + time_end: Optional[time] = None + derived_from_template_id: Optional[int] = None + derived_from_unit_id: Optional[int] = None + title: Optional[str] = None + goal: Optional[str] = None + focus_areas: Optional[List[str]] = [] + +class TrainingUnitResponse(BaseModel): + id: int + group_id: int + date: date + time_start: Optional[time] + time_end: Optional[time] + title: Optional[str] + goal: Optional[str] + focus_areas: Optional[List[str]] + completion_status: str + created_by: int + created_at: datetime + updated_at: datetime + +# ============================================================================ +# Import +# ============================================================================ + +class WikiImportRequest(BaseModel): + import_type: str # skill, method, exercise + wiki_url: Optional[str] = None + dry_run: bool = False + +class WikiImportResponse(BaseModel): + import_status: str + items_total: int + items_imported: int + items_failed: int + error_log: Optional[List[str]] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..445b62d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +anthropic==0.26.0 +python-multipart==0.0.9 +Pillow==10.3.0 +aiofiles==23.2.1 +pydantic==2.7.1 +bcrypt==4.1.3 +slowapi==0.1.9 +psycopg2-binary==2.9.9 +python-dateutil==2.9.0 +tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows diff --git a/backend/version.py b/backend/version.py new file mode 100644 index 0000000..52ea8fd --- /dev/null +++ b/backend/version.py @@ -0,0 +1,38 @@ +# Shinkan Jinkendo Version Information + +APP_VERSION = "0.1.0" +BUILD_DATE = "2026-04-21" +DB_SCHEMA_VERSION = "20260421" + +MODULE_VERSIONS = { + "auth": "1.0.0", + "profiles": "1.0.0", + "clubs": "0.1.0", + "groups": "0.1.0", + "skills": "0.1.0", + "methods": "0.1.0", + "exercises": "0.1.0", + "training_units": "0.1.0", + "training_programs": "0.1.0", + "planning": "0.1.0", + "import_wiki": "0.1.0", + "admin": "1.0.0", + "membership": "1.0.0", +} + +CHANGELOG = [ + { + "version": "0.1.0", + "date": "2026-04-21", + "changes": [ + "Initial MVP Setup", + "Feature: Übungsverwaltung (Kern-Modul)", + "Feature: Fähigkeiten- und Methodenkataloge", + "Feature: Trainingsplanung für Gruppen", + "Feature: Trainingsabschnitte mit Kombinations-Flag", + "Feature: MediaWiki-Import (einseitig)", + "Feature: Freigabelogik (privat/Verein/offiziell)", + "Infrastructure: Auth + Membership von Mitai übernommen", + ] + } +]