From a849d5db9e7a003d8149579803406316d4cbb71d Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 19 Mar 2026 13:09:33 +0100 Subject: [PATCH] feat: add admin management routers for subscription system Five new admin routers: 1. routers/features.py - GET/POST/PUT/DELETE /api/features - Feature registry CRUD - Allows adding new limitable features without schema changes 2. routers/tiers_mgmt.py - GET/POST/PUT/DELETE /api/tiers - Subscription tier management - Price configuration, sort order 3. routers/tier_limits.py - GET /api/tier-limits - Complete Tier x Feature matrix - PUT /api/tier-limits - Update single limit - PUT /api/tier-limits/batch - Batch update - DELETE /api/tier-limits - Remove limit (fallback to default) - Matrix editor backend 4. routers/user_restrictions.py - GET/POST/PUT/DELETE /api/user-restrictions - User-specific feature overrides - Highest priority in access hierarchy - Includes reason field for documentation 5. routers/access_grants.py - GET /api/access-grants - List grants with filters - POST /api/access-grants - Manual grant creation - PUT /api/access-grants/{id} - Extend/pause grants - DELETE /api/access-grants/{id} - Revoke access - Activity logging All endpoints require admin authentication. Completes backend API for v9c Phase 2. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 11 +- backend/routers/access_grants.py | 192 +++++++++++++++++++++++++++ backend/routers/features.py | 121 +++++++++++++++++ backend/routers/tier_limits.py | 158 ++++++++++++++++++++++ backend/routers/tiers_mgmt.py | 117 ++++++++++++++++ backend/routers/user_restrictions.py | 140 +++++++++++++++++++ 6 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 backend/routers/access_grants.py create mode 100644 backend/routers/features.py create mode 100644 backend/routers/tier_limits.py create mode 100644 backend/routers/tiers_mgmt.py create mode 100644 backend/routers/user_restrictions.py diff --git a/backend/main.py b/backend/main.py index 37b06f4..f4bf4f8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,9 @@ from db import init_db # Import routers from routers import auth, profiles, weight, circumference, caliper from routers import activity, nutrition, photos, insights, prompts -from routers import admin, stats, exportdata, importdata, subscription, coupons +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")) @@ -73,8 +75,15 @@ app.include_router(admin.router) # /api/admin/* 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("/") 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/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/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}