Merge pull request '9c Phase 2' (#6) from develop into main
Reviewed-on: #6
This commit is contained in:
commit
272c123952
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%)
|
- ✅ **Modulare Backend-Architektur**: 14 Router-Module, main.py von 1878→75 Zeilen (-96%)
|
||||||
|
|
||||||
### Was in v9c kommt: Subscription & Coupon Management System
|
### 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)
|
- 🔲 Selbst-Registrierung mit E-Mail-Verifizierung (Pflicht)
|
||||||
- 🔲 Flexibles Tier-System (free/basic/premium/selfhosted) - Admin-editierbar
|
- 🔲 Trial-System UI (Dauer konfigurierbar, auto-start nach E-Mail-Verifikation)
|
||||||
- 🔲 Trial-System (Dauer konfigurierbar, auto-start nach E-Mail-Verifikation)
|
- 🔲 Admin Matrix-Editor (Tier x Feature Limits)
|
||||||
- 🔲 **Coupon-System** (2 Typen):
|
- 🔲 Admin Coupon-Manager (CRUD, Redemption-Historie)
|
||||||
- Single-Use Coupons (Geschenke, zeitlich begrenzt)
|
- 🔲 Admin User-Restrictions UI
|
||||||
- Multi-Use Period Coupons (z.B. Wellpass, monatlich erneuerbar)
|
- 🔲 User Subscription-Info Page
|
||||||
- 🔲 Coupon-Stacking-Logik (Pause + Resume bei Wellpass-Override)
|
- 🔲 User Coupon-Einlösung UI
|
||||||
- 🔲 Access-Grant-System (zeitlich begrenzte Zugriffe mit Quelle-Tracking)
|
- 🔲 App-Settings Admin-Panel (globale Konfiguration)
|
||||||
- 🔲 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)
|
|
||||||
|
|
||||||
**E-Mail Templates (v9c):**
|
**E-Mail Templates (v9c):**
|
||||||
- 🔲 Registrierung + E-Mail-Verifizierung
|
- 🔲 Registrierung + E-Mail-Verifizierung
|
||||||
|
|
@ -892,6 +903,12 @@ Dev: dev-mitai-api, dev-mitai-ui
|
||||||
|
|
||||||
## Bekannte Probleme & Lösungen
|
## 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
|
### dayjs.week() – NIEMALS verwenden
|
||||||
```javascript
|
```javascript
|
||||||
// ❌ Falsch:
|
// ❌ Falsch:
|
||||||
|
|
|
||||||
235
backend/auth.py
235
backend/auth.py
|
|
@ -7,6 +7,7 @@ for FastAPI endpoints.
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from fastapi import Header, Query, HTTPException
|
from fastapi import Header, Query, HTTPException
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
|
|
@ -114,3 +115,237 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
|
||||||
if session['role'] != 'admin':
|
if session['role'] != 'admin':
|
||||||
raise HTTPException(403, "Nur für Admins")
|
raise HTTPException(403, "Nur für Admins")
|
||||||
return session
|
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 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
|
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"))
|
||||||
|
|
@ -74,6 +76,15 @@ 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(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("/")
|
||||||
def root():
|
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