9c Phase 2 #6
43
CLAUDE.md
43
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:
|
||||
|
|
|
|||
235
backend/auth.py
235
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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
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}
|
||||
282
backend/routers/coupons.py
Normal file
282
backend/routers/coupons.py
Normal file
|
|
@ -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
|
||||
}
|
||||
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}
|
||||
187
backend/routers/subscription.py
Normal file
187
backend/routers/subscription.py
Normal file
|
|
@ -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
|
||||
}
|
||||
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