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 <noreply@anthropic.com>
This commit is contained in:
parent
ae9743d6ed
commit
a849d5db9e
|
|
@ -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("/")
|
||||||
|
|
|
||||||
192
backend/routers/access_grants.py
Normal file
192
backend/routers/access_grants.py
Normal 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
121
backend/routers/features.py
Normal 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}
|
||||||
158
backend/routers/tier_limits.py
Normal file
158
backend/routers/tier_limits.py
Normal 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}
|
||||||
117
backend/routers/tiers_mgmt.py
Normal file
117
backend/routers/tiers_mgmt.py
Normal 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}
|
||||||
140
backend/routers/user_restrictions.py
Normal file
140
backend/routers/user_restrictions.py
Normal 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}
|
||||||
Loading…
Reference in New Issue
Block a user