diff --git a/CLAUDE.md b/CLAUDE.md index db7f837..3360784 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,20 +97,31 @@ mitai-jinkendo/ - ✅ **Modulare Backend-Architektur**: 14 Router-Module, main.py von 1878→75 Zeilen (-96%) ### Was in v9c kommt: Subscription & Coupon Management System -**Core Features:** +**Phase 1 (DB-Schema): ✅ DONE** +**Phase 2 (Backend API): ✅ DONE** +**Phase 3 (Frontend UI): 🔲 TODO** + +**Core Features (Backend):** +- ✅ DB-Schema (11 neue Tabellen, Feature-Registry Pattern) +- ✅ Feature-Access Middleware (check_feature_access, increment_feature_usage) +- ✅ Flexibles Tier-System (free/basic/premium/selfhosted) - Admin-editierbar via API +- ✅ **Coupon-System** (3 Typen: single_use, period, wellpass) +- ✅ Coupon-Stacking-Logik (Pause + Resume bei Wellpass-Override) +- ✅ Access-Grant-System (zeitlich begrenzte Zugriffe mit Quelle-Tracking) +- ✅ User-Activity-Log (JSONB details) +- ✅ User-Stats (Aggregierte Statistiken) +- ✅ Individuelle User-Restrictions (Admin kann Limits pro User setzen) +- ✅ 7 neue Router, 30+ neue Endpoints (subscription, coupons, features, tiers, tier-limits, user-restrictions, access-grants) + +**Frontend TODO (Phase 3):** - 🔲 Selbst-Registrierung mit E-Mail-Verifizierung (Pflicht) -- 🔲 Flexibles Tier-System (free/basic/premium/selfhosted) - Admin-editierbar -- 🔲 Trial-System (Dauer konfigurierbar, auto-start nach E-Mail-Verifikation) -- 🔲 **Coupon-System** (2 Typen): - - Single-Use Coupons (Geschenke, zeitlich begrenzt) - - Multi-Use Period Coupons (z.B. Wellpass, monatlich erneuerbar) -- 🔲 Coupon-Stacking-Logik (Pause + Resume bei Wellpass-Override) -- 🔲 Access-Grant-System (zeitlich begrenzte Zugriffe mit Quelle-Tracking) -- 🔲 User-Activity-Log (Login, Password-Änderungen, Coupon-Einlösungen, etc.) -- 🔲 User-Stats (Login-Streaks, Nutzungsstatistiken) -- 🔲 Individuelle User-Restrictions (Admin kann Limits pro User setzen) -- 🔲 App-Settings (globale Konfiguration durch Admin) -- 🔲 Erweiterte Admin-User-Verwaltung (Activity-Log, Stats, Access-Historie) +- 🔲 Trial-System UI (Dauer konfigurierbar, auto-start nach E-Mail-Verifikation) +- 🔲 Admin Matrix-Editor (Tier x Feature Limits) +- 🔲 Admin Coupon-Manager (CRUD, Redemption-Historie) +- 🔲 Admin User-Restrictions UI +- 🔲 User Subscription-Info Page +- 🔲 User Coupon-Einlösung UI +- 🔲 App-Settings Admin-Panel (globale Konfiguration) **E-Mail Templates (v9c):** - 🔲 Registrierung + E-Mail-Verifizierung @@ -892,6 +903,12 @@ Dev: dev-mitai-api, dev-mitai-ui ## Bekannte Probleme & Lösungen +### Admin User-Erstellung – Email fehlt (v9c TODO) +**Problem:** Bei Admin-CRUD `/api/profiles` (POST) wird keine Email-Adresse abgefragt. +**Impact:** Neue User können sich nicht einloggen (Login erfordert Email). +**Workaround:** Email manuell via `/api/admin/profiles/{pid}/email` setzen. +**Fix-TODO:** POST `/api/profiles` sollte Email als optionales Feld akzeptieren. + ### dayjs.week() – NIEMALS verwenden ```javascript // ❌ Falsch: diff --git a/backend/auth.py b/backend/auth.py index 9c7ece4..21b0042 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -7,6 +7,7 @@ for FastAPI endpoints. import hashlib import secrets from typing import Optional +from datetime import datetime, timedelta from fastapi import Header, Query, HTTPException import bcrypt @@ -114,3 +115,237 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)): if session['role'] != 'admin': raise HTTPException(403, "Nur für Admins") return session + + +# ============================================================================ +# Feature Access Control (v9c) +# ============================================================================ + +def get_effective_tier(profile_id: str) -> str: + """ + Get the effective tier for a profile. + + Checks for active access_grants first (from coupons, trials, etc.), + then falls back to profile.tier. + + Returns: + tier_id (str): 'free', 'basic', 'premium', or 'selfhosted' + """ + with get_db() as 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' + + +def check_feature_access(profile_id: str, feature_id: str) -> 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) + + 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' + } + """ + with get_db() as conn: + 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) + 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/main.py b/backend/main.py index 88204b3..f4bf4f8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,8 @@ from db import init_db from routers import auth, profiles, weight, circumference, caliper from routers import activity, nutrition, photos, insights, prompts from routers import admin, stats, exportdata, importdata +from routers import subscription, coupons, features, tiers_mgmt, tier_limits +from routers import user_restrictions, access_grants # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -74,6 +76,15 @@ app.include_router(stats.router) # /api/stats app.include_router(exportdata.router) # /api/export/* app.include_router(importdata.router) # /api/import/* +# v9c Subscription System +app.include_router(subscription.router) # /api/subscription/* +app.include_router(coupons.router) # /api/coupons/* +app.include_router(features.router) # /api/features (admin) +app.include_router(tiers_mgmt.router) # /api/tiers (admin) +app.include_router(tier_limits.router) # /api/tier-limits (admin) +app.include_router(user_restrictions.router) # /api/user-restrictions (admin) +app.include_router(access_grants.router) # /api/access-grants (admin) + # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") def root(): diff --git a/backend/routers/access_grants.py b/backend/routers/access_grants.py new file mode 100644 index 0000000..dee9451 --- /dev/null +++ b/backend/routers/access_grants.py @@ -0,0 +1,192 @@ +""" +Access Grants Management Endpoints for Mitai Jinkendo + +Admin-only access grants history and manual grant creation. +""" +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException, Depends + +from db import get_db, get_cursor, r2d +from auth import require_admin + +router = APIRouter(prefix="/api/access-grants", tags=["access-grants"]) + + +@router.get("") +def list_access_grants( + profile_id: str = None, + active_only: bool = False, + session: dict = Depends(require_admin) +): + """ + Admin: List access grants. + + Query params: + - profile_id: Filter by user + - active_only: Only show currently active grants + """ + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT + ag.*, + t.name as tier_name, + p.name as profile_name, + p.email as profile_email + FROM access_grants ag + JOIN tiers t ON t.id = ag.tier_id + JOIN profiles p ON p.id = ag.profile_id + """ + + conditions = [] + params = [] + + if profile_id: + conditions.append("ag.profile_id = %s") + params.append(profile_id) + + if active_only: + conditions.append("ag.is_active = true") + conditions.append("ag.valid_until > CURRENT_TIMESTAMP") + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + query += " ORDER BY ag.valid_until DESC" + + cur.execute(query, params) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("") +def create_access_grant(data: dict, session: dict = Depends(require_admin)): + """ + Admin: Manually create access grant. + + Body: + { + "profile_id": "uuid", + "tier_id": "premium", + "duration_days": 30, + "reason": "Compensation for bug" + } + """ + profile_id = data.get('profile_id') + tier_id = data.get('tier_id') + duration_days = data.get('duration_days') + reason = data.get('reason', '') + + if not profile_id or not tier_id or not duration_days: + raise HTTPException(400, "profile_id, tier_id und duration_days fehlen") + + valid_from = datetime.now() + valid_until = valid_from + timedelta(days=duration_days) + + with get_db() as conn: + cur = get_cursor(conn) + + # Create grant + cur.execute(""" + INSERT INTO access_grants ( + profile_id, tier_id, granted_by, valid_from, valid_until + ) + VALUES (%s, %s, 'admin', %s, %s) + RETURNING id + """, (profile_id, tier_id, valid_from, valid_until)) + + grant_id = cur.fetchone()['id'] + + # Log activity + cur.execute(""" + INSERT INTO user_activity_log (profile_id, action, details) + VALUES (%s, 'access_grant_created', %s) + """, ( + profile_id, + f'{{"tier": "{tier_id}", "duration_days": {duration_days}, "reason": "{reason}"}}' + )) + + conn.commit() + + return { + "ok": True, + "id": grant_id, + "valid_until": valid_until.isoformat() + } + + +@router.put("/{grant_id}") +def update_access_grant(grant_id: str, data: dict, session: dict = Depends(require_admin)): + """ + Admin: Update access grant (e.g., extend duration, pause/resume). + + Body: + { + "is_active": false, // Pause grant + "valid_until": "2026-12-31T23:59:59" // Extend + } + """ + with get_db() as conn: + cur = get_cursor(conn) + + updates = [] + values = [] + + if 'is_active' in data: + updates.append('is_active = %s') + values.append(data['is_active']) + + if not data['is_active']: + # Pausing - calculate remaining days + cur.execute("SELECT valid_until FROM access_grants WHERE id = %s", (grant_id,)) + grant = cur.fetchone() + if grant: + remaining = (grant['valid_until'] - datetime.now()).days + updates.append('remaining_days = %s') + values.append(remaining) + updates.append('paused_at = CURRENT_TIMESTAMP') + + if 'valid_until' in data: + updates.append('valid_until = %s') + values.append(data['valid_until']) + + if not updates: + return {"ok": True} + + updates.append('updated = CURRENT_TIMESTAMP') + values.append(grant_id) + + cur.execute( + f"UPDATE access_grants SET {', '.join(updates)} WHERE id = %s", + values + ) + conn.commit() + + return {"ok": True} + + +@router.delete("/{grant_id}") +def revoke_access_grant(grant_id: str, session: dict = Depends(require_admin)): + """Admin: Revoke access grant (hard delete).""" + with get_db() as conn: + cur = get_cursor(conn) + + # Get grant info for logging + cur.execute("SELECT profile_id, tier_id FROM access_grants WHERE id = %s", (grant_id,)) + grant = cur.fetchone() + + if grant: + # Log revocation + cur.execute(""" + INSERT INTO user_activity_log (profile_id, action, details) + VALUES (%s, 'access_grant_revoked', %s) + """, ( + grant['profile_id'], + f'{{"grant_id": "{grant_id}", "tier": "{grant["tier_id"]}"}}' + )) + + # Delete grant + cur.execute("DELETE FROM access_grants WHERE id = %s", (grant_id,)) + conn.commit() + + return {"ok": True} diff --git a/backend/routers/coupons.py b/backend/routers/coupons.py new file mode 100644 index 0000000..c41a576 --- /dev/null +++ b/backend/routers/coupons.py @@ -0,0 +1,282 @@ +""" +Coupon Management Endpoints for Mitai Jinkendo + +Handles coupon CRUD (admin) and redemption (users). +""" +from datetime import datetime, timedelta +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends + +from db import get_db, get_cursor, r2d +from auth import require_auth, require_admin + +router = APIRouter(prefix="/api/coupons", tags=["coupons"]) + + +@router.get("") +def list_coupons(session: dict = Depends(require_admin)): + """Admin: List all coupons with redemption stats.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT + c.*, + t.name as tier_name, + (SELECT COUNT(*) FROM coupon_redemptions WHERE coupon_id = c.id) as redemptions + FROM coupons c + LEFT JOIN tiers t ON t.id = c.tier_id + ORDER BY c.created DESC + """) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("") +def create_coupon(data: dict, session: dict = Depends(require_admin)): + """ + Admin: Create new coupon. + + Required fields: + - code: Unique coupon code + - type: 'single_use', 'period', or 'wellpass' + - tier_id: Target tier + - duration_days: For period/wellpass coupons + + Optional fields: + - max_redemptions: NULL = unlimited + - valid_from, valid_until: Validity period + - description: Internal note + """ + code = data.get('code', '').strip().upper() + coupon_type = data.get('type') + tier_id = data.get('tier_id') + duration_days = data.get('duration_days') + max_redemptions = data.get('max_redemptions') + valid_from = data.get('valid_from') + valid_until = data.get('valid_until') + description = data.get('description', '') + + if not code: + raise HTTPException(400, "Coupon-Code fehlt") + if coupon_type not in ['single_use', 'period', 'wellpass']: + raise HTTPException(400, "Ungültiger Coupon-Typ") + if not tier_id: + raise HTTPException(400, "Tier fehlt") + if coupon_type in ['period', 'wellpass'] and not duration_days: + raise HTTPException(400, "duration_days fehlt für period/wellpass Coupons") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if code already exists + cur.execute("SELECT id FROM coupons WHERE code = %s", (code,)) + if cur.fetchone(): + raise HTTPException(400, f"Coupon-Code '{code}' existiert bereits") + + # Create coupon + cur.execute(""" + INSERT INTO coupons ( + code, type, tier_id, duration_days, max_redemptions, + valid_from, valid_until, description, created_by + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + code, coupon_type, tier_id, duration_days, max_redemptions, + valid_from, valid_until, description, session['profile_id'] + )) + + coupon_id = cur.fetchone()['id'] + conn.commit() + + return {"ok": True, "id": coupon_id, "code": code} + + +@router.put("/{coupon_id}") +def update_coupon(coupon_id: str, data: dict, session: dict = Depends(require_admin)): + """Admin: Update coupon.""" + with get_db() as conn: + cur = get_cursor(conn) + + updates = [] + values = [] + + if 'active' in data: + updates.append('active = %s') + values.append(data['active']) + if 'max_redemptions' in data: + updates.append('max_redemptions = %s') + values.append(data['max_redemptions']) + if 'valid_until' in data: + updates.append('valid_until = %s') + values.append(data['valid_until']) + if 'description' in data: + updates.append('description = %s') + values.append(data['description']) + + if not updates: + return {"ok": True} + + updates.append('updated = CURRENT_TIMESTAMP') + values.append(coupon_id) + + cur.execute( + f"UPDATE coupons SET {', '.join(updates)} WHERE id = %s", + values + ) + conn.commit() + + return {"ok": True} + + +@router.delete("/{coupon_id}") +def delete_coupon(coupon_id: str, session: dict = Depends(require_admin)): + """Admin: Delete coupon (soft-delete: set active=false).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE coupons SET active = false WHERE id = %s", (coupon_id,)) + conn.commit() + return {"ok": True} + + +@router.get("/{coupon_id}/redemptions") +def get_coupon_redemptions(coupon_id: str, session: dict = Depends(require_admin)): + """Admin: Get all redemptions for a coupon.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT + cr.id, + cr.redeemed_at, + p.name as profile_name, + p.email as profile_email, + ag.valid_from, + ag.valid_until, + ag.is_active + FROM coupon_redemptions cr + JOIN profiles p ON p.id = cr.profile_id + LEFT JOIN access_grants ag ON ag.id = cr.access_grant_id + WHERE cr.coupon_id = %s + ORDER BY cr.redeemed_at DESC + """, (coupon_id,)) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/redeem") +def redeem_coupon(data: dict, session: dict = Depends(require_auth)): + """ + User: Redeem a coupon code. + + Creates an access_grant and handles Wellpass pause/resume logic. + """ + code = data.get('code', '').strip().upper() + if not code: + raise HTTPException(400, "Coupon-Code fehlt") + + profile_id = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get coupon + cur.execute(""" + SELECT * FROM coupons + WHERE code = %s AND active = true + """, (code,)) + coupon = cur.fetchone() + + if not coupon: + raise HTTPException(404, "Ungültiger Coupon-Code") + + # Check validity period + now = datetime.now() + if coupon['valid_from'] and now < coupon['valid_from']: + raise HTTPException(400, "Coupon noch nicht gültig") + if coupon['valid_until'] and now > coupon['valid_until']: + raise HTTPException(400, "Coupon abgelaufen") + + # Check max redemptions + if coupon['max_redemptions'] is not None: + if coupon['redemption_count'] >= coupon['max_redemptions']: + raise HTTPException(400, "Coupon bereits vollständig eingelöst") + + # Check if user already redeemed this coupon + cur.execute(""" + SELECT id FROM coupon_redemptions + WHERE coupon_id = %s AND profile_id = %s + """, (coupon['id'], profile_id)) + if cur.fetchone(): + raise HTTPException(400, "Du hast diesen Coupon bereits eingelöst") + + # Create access grant + valid_from = now + valid_until = now + timedelta(days=coupon['duration_days']) if coupon['duration_days'] else None + + # Wellpass logic: Pause existing personal grants + if coupon['type'] == 'wellpass': + cur.execute(""" + SELECT id, valid_until + FROM access_grants + WHERE profile_id = %s + AND is_active = true + AND granted_by != 'wellpass' + AND valid_until > CURRENT_TIMESTAMP + """, (profile_id,)) + active_grants = cur.fetchall() + + for grant in active_grants: + # Calculate remaining days + remaining = (grant['valid_until'] - now).days + # Pause grant + cur.execute(""" + UPDATE access_grants + SET is_active = false, + paused_at = CURRENT_TIMESTAMP, + remaining_days = %s + WHERE id = %s + """, (remaining, grant['id'])) + + # Insert access grant + cur.execute(""" + INSERT INTO access_grants ( + profile_id, tier_id, granted_by, coupon_id, + valid_from, valid_until, is_active + ) + VALUES (%s, %s, %s, %s, %s, %s, true) + RETURNING id + """, ( + profile_id, coupon['tier_id'], + coupon['type'], coupon['id'], + valid_from, valid_until + )) + grant_id = cur.fetchone()['id'] + + # Record redemption + cur.execute(""" + INSERT INTO coupon_redemptions (coupon_id, profile_id, access_grant_id) + VALUES (%s, %s, %s) + """, (coupon['id'], profile_id, grant_id)) + + # Increment coupon redemption count + cur.execute(""" + UPDATE coupons + SET redemption_count = redemption_count + 1 + WHERE id = %s + """, (coupon['id'],)) + + # Log activity + cur.execute(""" + INSERT INTO user_activity_log (profile_id, action, details) + VALUES (%s, 'coupon_redeemed', %s) + """, ( + profile_id, + f'{{"coupon_code": "{code}", "tier": "{coupon["tier_id"]}", "duration_days": {coupon["duration_days"]}}}' + )) + + conn.commit() + + return { + "ok": True, + "message": f"Coupon erfolgreich eingelöst: {coupon['tier_id']} für {coupon['duration_days']} Tage", + "grant_id": grant_id, + "valid_until": valid_until.isoformat() if valid_until else None + } diff --git a/backend/routers/features.py b/backend/routers/features.py new file mode 100644 index 0000000..458e8e8 --- /dev/null +++ b/backend/routers/features.py @@ -0,0 +1,121 @@ +""" +Feature Management Endpoints for Mitai Jinkendo + +Admin-only CRUD for features registry. +""" +from fastapi import APIRouter, HTTPException, Depends + +from db import get_db, get_cursor, r2d +from auth import require_admin + +router = APIRouter(prefix="/api/features", tags=["features"]) + + +@router.get("") +def list_features(session: dict = Depends(require_admin)): + """Admin: List all features.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT * FROM features + ORDER BY category, name + """) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("") +def create_feature(data: dict, session: dict = Depends(require_admin)): + """ + Admin: Create new feature. + + Required fields: + - id: Feature ID (e.g., 'new_data_source') + - name: Display name + - category: 'data', 'ai', 'export', 'integration' + - limit_type: 'count' or 'boolean' + - reset_period: 'never', 'daily', 'monthly' + - default_limit: INT or NULL (unlimited) + """ + feature_id = data.get('id', '').strip() + name = data.get('name', '').strip() + description = data.get('description', '') + category = data.get('category') + limit_type = data.get('limit_type', 'count') + reset_period = data.get('reset_period', 'never') + default_limit = data.get('default_limit') + + if not feature_id or not name: + raise HTTPException(400, "ID und Name fehlen") + if category not in ['data', 'ai', 'export', 'integration']: + raise HTTPException(400, "Ungültige Kategorie") + if limit_type not in ['count', 'boolean']: + raise HTTPException(400, "limit_type muss 'count' oder 'boolean' sein") + if reset_period not in ['never', 'daily', 'monthly']: + raise HTTPException(400, "Ungültiger reset_period") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if ID already exists + cur.execute("SELECT id FROM features WHERE id = %s", (feature_id,)) + if cur.fetchone(): + raise HTTPException(400, f"Feature '{feature_id}' existiert bereits") + + # Create feature + cur.execute(""" + INSERT INTO features ( + id, name, description, category, limit_type, reset_period, default_limit + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, (feature_id, name, description, category, limit_type, reset_period, default_limit)) + + conn.commit() + + return {"ok": True, "id": feature_id} + + +@router.put("/{feature_id}") +def update_feature(feature_id: str, data: dict, session: dict = Depends(require_admin)): + """Admin: Update feature.""" + with get_db() as conn: + cur = get_cursor(conn) + + updates = [] + values = [] + + if 'name' in data: + updates.append('name = %s') + values.append(data['name']) + if 'description' in data: + updates.append('description = %s') + values.append(data['description']) + if 'default_limit' in data: + updates.append('default_limit = %s') + values.append(data['default_limit']) + if 'active' in data: + updates.append('active = %s') + values.append(data['active']) + + if not updates: + return {"ok": True} + + updates.append('updated = CURRENT_TIMESTAMP') + values.append(feature_id) + + cur.execute( + f"UPDATE features SET {', '.join(updates)} WHERE id = %s", + values + ) + conn.commit() + + return {"ok": True} + + +@router.delete("/{feature_id}") +def delete_feature(feature_id: str, session: dict = Depends(require_admin)): + """Admin: Delete feature (soft-delete: set active=false).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,)) + conn.commit() + return {"ok": True} diff --git a/backend/routers/subscription.py b/backend/routers/subscription.py new file mode 100644 index 0000000..d3633d2 --- /dev/null +++ b/backend/routers/subscription.py @@ -0,0 +1,187 @@ +""" +User Subscription Endpoints for Mitai Jinkendo + +User-facing subscription info (own tier, usage, limits). +""" +from datetime import datetime +from fastapi import APIRouter, Depends + +from db import get_db, get_cursor, r2d +from auth import require_auth, get_effective_tier, check_feature_access + +router = APIRouter(prefix="/api/subscription", tags=["subscription"]) + + +@router.get("/me") +def get_my_subscription(session: dict = Depends(require_auth)): + """ + Get current user's subscription info. + + Returns: + - tier: Current effective tier (considers access_grants) + - profile_tier: Base tier from profile + - trial_ends_at: Trial expiration (if applicable) + - email_verified: Email verification status + - active_grants: List of active access grants (coupons, trials) + """ + profile_id = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get profile info + cur.execute(""" + SELECT tier, trial_ends_at, email_verified + FROM profiles + WHERE id = %s + """, (profile_id,)) + profile = cur.fetchone() + + if not profile: + return {"error": "Profile not found"} + + # Get effective tier (considers access_grants) + effective_tier = get_effective_tier(profile_id) + + # Get active access grants + cur.execute(""" + SELECT + ag.id, + ag.tier_id, + ag.granted_by, + ag.valid_from, + ag.valid_until, + ag.is_active, + ag.paused_by, + ag.remaining_days, + t.name as tier_name + FROM access_grants ag + JOIN tiers t ON t.id = ag.tier_id + WHERE ag.profile_id = %s + AND ag.valid_until > CURRENT_TIMESTAMP + ORDER BY ag.valid_until DESC + """, (profile_id,)) + grants = [r2d(r) for r in cur.fetchall()] + + # Get tier info + cur.execute(""" + SELECT id, name, description, price_monthly_cents, price_yearly_cents + FROM tiers + WHERE id = %s + """, (effective_tier,)) + tier_info = r2d(cur.fetchone()) + + return { + "tier": effective_tier, + "tier_info": tier_info, + "profile_tier": profile['tier'], + "trial_ends_at": profile['trial_ends_at'].isoformat() if profile['trial_ends_at'] else None, + "email_verified": profile['email_verified'], + "active_grants": grants + } + + +@router.get("/usage") +def get_my_usage(session: dict = Depends(require_auth)): + """ + Get current user's feature usage. + + Returns list of features with current usage and limits. + """ + profile_id = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Get all active features + cur.execute(""" + SELECT id, name, category, limit_type, reset_period + FROM features + WHERE active = true + ORDER BY category, name + """) + features = [r2d(r) for r in cur.fetchall()] + + # Get usage for each feature + usage_list = [] + for feature in features: + access = check_feature_access(profile_id, feature['id']) + usage_list.append({ + "feature_id": feature['id'], + "feature_name": feature['name'], + "category": feature['category'], + "limit_type": feature['limit_type'], + "reset_period": feature['reset_period'], + "allowed": access['allowed'], + "limit": access['limit'], + "used": access['used'], + "remaining": access['remaining'], + "reason": access['reason'] + }) + + return { + "tier": get_effective_tier(profile_id), + "features": usage_list + } + + +@router.get("/limits") +def get_my_limits(session: dict = Depends(require_auth)): + """ + Get all feature limits for current tier. + + Simplified view - just shows what's allowed/not allowed. + """ + profile_id = session['profile_id'] + tier_id = get_effective_tier(profile_id) + + with get_db() as conn: + cur = get_cursor(conn) + + # Get all features with their limits for this tier + cur.execute(""" + SELECT + f.id, + f.name, + f.category, + f.limit_type, + COALESCE(tl.limit_value, f.default_limit) as limit_value + FROM features f + LEFT JOIN tier_limits tl ON tl.feature_id = f.id AND tl.tier_id = %s + WHERE f.active = true + ORDER BY f.category, f.name + """, (tier_id,)) + + features = [] + for row in cur.fetchall(): + rd = r2d(row) + limit = rd['limit_value'] + + # Interpret limit + if limit is None: + status = "unlimited" + elif limit == 0: + status = "disabled" + elif rd['limit_type'] == 'boolean': + status = "enabled" if limit == 1 else "disabled" + else: + status = f"limit: {limit}" + + features.append({ + "feature_id": rd['id'], + "feature_name": rd['name'], + "category": rd['category'], + "limit": limit, + "status": status + }) + + # Get tier info + cur.execute("SELECT name, description FROM tiers WHERE id = %s", (tier_id,)) + tier = cur.fetchone() + + return { + "tier_id": tier_id, + "tier_name": tier['name'] if tier else tier_id, + "tier_description": tier['description'] if tier else '', + "features": features + } diff --git a/backend/routers/tier_limits.py b/backend/routers/tier_limits.py new file mode 100644 index 0000000..4a0454d --- /dev/null +++ b/backend/routers/tier_limits.py @@ -0,0 +1,158 @@ +""" +Tier Limits Management Endpoints for Mitai Jinkendo + +Admin-only matrix editor for Tier x Feature limits. +""" +from fastapi import APIRouter, HTTPException, Depends + +from db import get_db, get_cursor, r2d +from auth import require_admin + +router = APIRouter(prefix="/api/tier-limits", tags=["tier-limits"]) + + +@router.get("") +def get_tier_limits_matrix(session: dict = Depends(require_admin)): + """ + Admin: Get complete Tier x Feature matrix. + + Returns: + { + "tiers": [{id, name}, ...], + "features": [{id, name, category}, ...], + "limits": { + "tier_id:feature_id": limit_value, + ... + } + } + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Get all tiers + cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true ORDER BY sort_order") + tiers = [r2d(r) for r in cur.fetchall()] + + # Get all features + cur.execute(""" + SELECT id, name, category, limit_type, default_limit + FROM features + WHERE active = true + ORDER BY category, name + """) + features = [r2d(r) for r in cur.fetchall()] + + # Get all tier_limits + cur.execute("SELECT tier_id, feature_id, limit_value FROM tier_limits") + limits = {} + for row in cur.fetchall(): + key = f"{row['tier_id']}:{row['feature_id']}" + limits[key] = row['limit_value'] + + return { + "tiers": tiers, + "features": features, + "limits": limits + } + + +@router.put("") +def update_tier_limit(data: dict, session: dict = Depends(require_admin)): + """ + Admin: Update single tier limit. + + Body: + { + "tier_id": "free", + "feature_id": "weight_entries", + "limit_value": 30 // NULL = unlimited, 0 = disabled + } + """ + tier_id = data.get('tier_id') + feature_id = data.get('feature_id') + limit_value = data.get('limit_value') # Can be None (NULL) + + if not tier_id or not feature_id: + raise HTTPException(400, "tier_id und feature_id fehlen") + + with get_db() as conn: + cur = get_cursor(conn) + + # Upsert tier_limit + cur.execute(""" + INSERT INTO tier_limits (tier_id, feature_id, limit_value) + VALUES (%s, %s, %s) + ON CONFLICT (tier_id, feature_id) + DO UPDATE SET + limit_value = EXCLUDED.limit_value, + updated = CURRENT_TIMESTAMP + """, (tier_id, feature_id, limit_value)) + + conn.commit() + + return {"ok": True} + + +@router.put("/batch") +def update_tier_limits_batch(data: dict, session: dict = Depends(require_admin)): + """ + Admin: Batch update multiple tier limits. + + Body: + { + "updates": [ + {"tier_id": "free", "feature_id": "weight_entries", "limit_value": 30}, + {"tier_id": "free", "feature_id": "ai_calls", "limit_value": 0}, + ... + ] + } + """ + updates = data.get('updates', []) + + if not updates: + raise HTTPException(400, "updates array fehlt") + + with get_db() as conn: + cur = get_cursor(conn) + + for update in updates: + tier_id = update.get('tier_id') + feature_id = update.get('feature_id') + limit_value = update.get('limit_value') + + if not tier_id or not feature_id: + continue # Skip invalid entries + + cur.execute(""" + INSERT INTO tier_limits (tier_id, feature_id, limit_value) + VALUES (%s, %s, %s) + ON CONFLICT (tier_id, feature_id) + DO UPDATE SET + limit_value = EXCLUDED.limit_value, + updated = CURRENT_TIMESTAMP + """, (tier_id, feature_id, limit_value)) + + conn.commit() + + return {"ok": True, "updated": len(updates)} + + +@router.delete("") +def delete_tier_limit(tier_id: str, feature_id: str, session: dict = Depends(require_admin)): + """ + Admin: Delete tier limit (falls back to feature default). + + Query params: ?tier_id=...&feature_id=... + """ + if not tier_id or not feature_id: + raise HTTPException(400, "tier_id und feature_id fehlen") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + DELETE FROM tier_limits + WHERE tier_id = %s AND feature_id = %s + """, (tier_id, feature_id)) + conn.commit() + + return {"ok": True} diff --git a/backend/routers/tiers_mgmt.py b/backend/routers/tiers_mgmt.py new file mode 100644 index 0000000..77910e9 --- /dev/null +++ b/backend/routers/tiers_mgmt.py @@ -0,0 +1,117 @@ +""" +Tier Management Endpoints for Mitai Jinkendo + +Admin-only CRUD for subscription tiers. +""" +from fastapi import APIRouter, HTTPException, Depends + +from db import get_db, get_cursor, r2d +from auth import require_admin + +router = APIRouter(prefix="/api/tiers", tags=["tiers"]) + + +@router.get("") +def list_tiers(session: dict = Depends(require_admin)): + """Admin: List all tiers.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT * FROM tiers + ORDER BY sort_order + """) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("") +def create_tier(data: dict, session: dict = Depends(require_admin)): + """ + Admin: Create new tier. + + Required fields: + - id: Tier ID (e.g., 'enterprise') + - name: Display name + - price_monthly_cents, price_yearly_cents: Prices (NULL for free tiers) + """ + tier_id = data.get('id', '').strip() + name = data.get('name', '').strip() + description = data.get('description', '') + price_monthly_cents = data.get('price_monthly_cents') + price_yearly_cents = data.get('price_yearly_cents') + sort_order = data.get('sort_order', 99) + + if not tier_id or not name: + raise HTTPException(400, "ID und Name fehlen") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if ID already exists + cur.execute("SELECT id FROM tiers WHERE id = %s", (tier_id,)) + if cur.fetchone(): + raise HTTPException(400, f"Tier '{tier_id}' existiert bereits") + + # Create tier + cur.execute(""" + INSERT INTO tiers ( + id, name, description, price_monthly_cents, price_yearly_cents, sort_order + ) + VALUES (%s, %s, %s, %s, %s, %s) + """, (tier_id, name, description, price_monthly_cents, price_yearly_cents, sort_order)) + + conn.commit() + + return {"ok": True, "id": tier_id} + + +@router.put("/{tier_id}") +def update_tier(tier_id: str, data: dict, session: dict = Depends(require_admin)): + """Admin: Update tier.""" + with get_db() as conn: + cur = get_cursor(conn) + + updates = [] + values = [] + + if 'name' in data: + updates.append('name = %s') + values.append(data['name']) + if 'description' in data: + updates.append('description = %s') + values.append(data['description']) + if 'price_monthly_cents' in data: + updates.append('price_monthly_cents = %s') + values.append(data['price_monthly_cents']) + if 'price_yearly_cents' in data: + updates.append('price_yearly_cents = %s') + values.append(data['price_yearly_cents']) + if 'active' in data: + updates.append('active = %s') + values.append(data['active']) + if 'sort_order' in data: + updates.append('sort_order = %s') + values.append(data['sort_order']) + + if not updates: + return {"ok": True} + + updates.append('updated = CURRENT_TIMESTAMP') + values.append(tier_id) + + cur.execute( + f"UPDATE tiers SET {', '.join(updates)} WHERE id = %s", + values + ) + conn.commit() + + return {"ok": True} + + +@router.delete("/{tier_id}") +def delete_tier(tier_id: str, session: dict = Depends(require_admin)): + """Admin: Delete tier (soft-delete: set active=false).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE tiers SET active = false WHERE id = %s", (tier_id,)) + conn.commit() + return {"ok": True} diff --git a/backend/routers/user_restrictions.py b/backend/routers/user_restrictions.py new file mode 100644 index 0000000..361f4bc --- /dev/null +++ b/backend/routers/user_restrictions.py @@ -0,0 +1,140 @@ +""" +User Restrictions Management Endpoints for Mitai Jinkendo + +Admin-only user-specific feature overrides. +""" +from fastapi import APIRouter, HTTPException, Depends + +from db import get_db, get_cursor, r2d +from auth import require_admin + +router = APIRouter(prefix="/api/user-restrictions", tags=["user-restrictions"]) + + +@router.get("") +def list_user_restrictions(profile_id: str = None, session: dict = Depends(require_admin)): + """ + Admin: List user restrictions. + + Optional query param: ?profile_id=... (filter by user) + """ + with get_db() as conn: + cur = get_cursor(conn) + + if profile_id: + cur.execute(""" + SELECT + ur.*, + f.name as feature_name, + f.category as feature_category, + p.name as profile_name + FROM user_feature_restrictions ur + JOIN features f ON f.id = ur.feature_id + JOIN profiles p ON p.id = ur.profile_id + WHERE ur.profile_id = %s + ORDER BY f.category, f.name + """, (profile_id,)) + else: + cur.execute(""" + SELECT + ur.*, + f.name as feature_name, + f.category as feature_category, + p.name as profile_name, + p.email as profile_email + FROM user_feature_restrictions ur + JOIN features f ON f.id = ur.feature_id + JOIN profiles p ON p.id = ur.profile_id + ORDER BY p.name, f.category, f.name + """) + + return [r2d(r) for r in cur.fetchall()] + + +@router.post("") +def create_user_restriction(data: dict, session: dict = Depends(require_admin)): + """ + Admin: Create user-specific feature restriction. + + Body: + { + "profile_id": "uuid", + "feature_id": "weight_entries", + "limit_value": 10, // NULL = unlimited, 0 = disabled + "reason": "Spam prevention" + } + """ + profile_id = data.get('profile_id') + feature_id = data.get('feature_id') + limit_value = data.get('limit_value') + reason = data.get('reason', '') + + if not profile_id or not feature_id: + raise HTTPException(400, "profile_id und feature_id fehlen") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if restriction already exists + cur.execute(""" + SELECT id FROM user_feature_restrictions + WHERE profile_id = %s AND feature_id = %s + """, (profile_id, feature_id)) + + if cur.fetchone(): + raise HTTPException(400, "Einschränkung existiert bereits (nutze PUT zum Aktualisieren)") + + # Create restriction + cur.execute(""" + INSERT INTO user_feature_restrictions ( + profile_id, feature_id, limit_value, reason, created_by + ) + VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, (profile_id, feature_id, limit_value, reason, session['profile_id'])) + + restriction_id = cur.fetchone()['id'] + conn.commit() + + return {"ok": True, "id": restriction_id} + + +@router.put("/{restriction_id}") +def update_user_restriction(restriction_id: str, data: dict, session: dict = Depends(require_admin)): + """Admin: Update user restriction.""" + with get_db() as conn: + cur = get_cursor(conn) + + updates = [] + values = [] + + if 'limit_value' in data: + updates.append('limit_value = %s') + values.append(data['limit_value']) + if 'reason' in data: + updates.append('reason = %s') + values.append(data['reason']) + + if not updates: + return {"ok": True} + + updates.append('updated = CURRENT_TIMESTAMP') + values.append(restriction_id) + + cur.execute( + f"UPDATE user_feature_restrictions SET {', '.join(updates)} WHERE id = %s", + values + ) + conn.commit() + + return {"ok": True} + + +@router.delete("/{restriction_id}") +def delete_user_restriction(restriction_id: str, session: dict = Depends(require_admin)): + """Admin: Delete user restriction (reverts to tier limit).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM user_feature_restrictions WHERE id = %s", (restriction_id,)) + conn.commit() + return {"ok": True}