feat: add admin management routers for subscription system
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-19 13:09:33 +01:00
parent ae9743d6ed
commit a849d5db9e
6 changed files with 738 additions and 1 deletions

View File

@ -17,7 +17,9 @@ from db import init_db
# Import routers # Import routers
from routers import auth, profiles, weight, circumference, caliper from routers import auth, profiles, weight, circumference, caliper
from routers import activity, nutrition, photos, insights, prompts 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 ───────────────────────────────────────────────────────── # ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) 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(stats.router) # /api/stats
app.include_router(exportdata.router) # /api/export/* app.include_router(exportdata.router) # /api/export/*
app.include_router(importdata.router) # /api/import/* app.include_router(importdata.router) # /api/import/*
# v9c Subscription System
app.include_router(subscription.router) # /api/subscription/* app.include_router(subscription.router) # /api/subscription/*
app.include_router(coupons.router) # /api/coupons/* 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 ────────────────────────────────────────────────────────────── # ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/") @app.get("/")

View File

@ -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}

121
backend/routers/features.py Normal file
View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}