Merge pull request '9c Phase 2' (#6) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s

Reviewed-on: #6
This commit is contained in:
Lars 2026-03-19 14:59:25 +01:00
commit 272c123952
10 changed files with 1473 additions and 13 deletions

View File

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

View File

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

View File

@ -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():

View File

@ -0,0 +1,192 @@
"""
Access Grants Management Endpoints for Mitai Jinkendo
Admin-only access grants history and manual grant creation.
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends
from db import get_db, get_cursor, r2d
from auth import require_admin
router = APIRouter(prefix="/api/access-grants", tags=["access-grants"])
@router.get("")
def list_access_grants(
profile_id: str = None,
active_only: bool = False,
session: dict = Depends(require_admin)
):
"""
Admin: List access grants.
Query params:
- profile_id: Filter by user
- active_only: Only show currently active grants
"""
with get_db() as conn:
cur = get_cursor(conn)
query = """
SELECT
ag.*,
t.name as tier_name,
p.name as profile_name,
p.email as profile_email
FROM access_grants ag
JOIN tiers t ON t.id = ag.tier_id
JOIN profiles p ON p.id = ag.profile_id
"""
conditions = []
params = []
if profile_id:
conditions.append("ag.profile_id = %s")
params.append(profile_id)
if active_only:
conditions.append("ag.is_active = true")
conditions.append("ag.valid_until > CURRENT_TIMESTAMP")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY ag.valid_until DESC"
cur.execute(query, params)
return [r2d(r) for r in cur.fetchall()]
@router.post("")
def create_access_grant(data: dict, session: dict = Depends(require_admin)):
"""
Admin: Manually create access grant.
Body:
{
"profile_id": "uuid",
"tier_id": "premium",
"duration_days": 30,
"reason": "Compensation for bug"
}
"""
profile_id = data.get('profile_id')
tier_id = data.get('tier_id')
duration_days = data.get('duration_days')
reason = data.get('reason', '')
if not profile_id or not tier_id or not duration_days:
raise HTTPException(400, "profile_id, tier_id und duration_days fehlen")
valid_from = datetime.now()
valid_until = valid_from + timedelta(days=duration_days)
with get_db() as conn:
cur = get_cursor(conn)
# Create grant
cur.execute("""
INSERT INTO access_grants (
profile_id, tier_id, granted_by, valid_from, valid_until
)
VALUES (%s, %s, 'admin', %s, %s)
RETURNING id
""", (profile_id, tier_id, valid_from, valid_until))
grant_id = cur.fetchone()['id']
# Log activity
cur.execute("""
INSERT INTO user_activity_log (profile_id, action, details)
VALUES (%s, 'access_grant_created', %s)
""", (
profile_id,
f'{{"tier": "{tier_id}", "duration_days": {duration_days}, "reason": "{reason}"}}'
))
conn.commit()
return {
"ok": True,
"id": grant_id,
"valid_until": valid_until.isoformat()
}
@router.put("/{grant_id}")
def update_access_grant(grant_id: str, data: dict, session: dict = Depends(require_admin)):
"""
Admin: Update access grant (e.g., extend duration, pause/resume).
Body:
{
"is_active": false, // Pause grant
"valid_until": "2026-12-31T23:59:59" // Extend
}
"""
with get_db() as conn:
cur = get_cursor(conn)
updates = []
values = []
if 'is_active' in data:
updates.append('is_active = %s')
values.append(data['is_active'])
if not data['is_active']:
# Pausing - calculate remaining days
cur.execute("SELECT valid_until FROM access_grants WHERE id = %s", (grant_id,))
grant = cur.fetchone()
if grant:
remaining = (grant['valid_until'] - datetime.now()).days
updates.append('remaining_days = %s')
values.append(remaining)
updates.append('paused_at = CURRENT_TIMESTAMP')
if 'valid_until' in data:
updates.append('valid_until = %s')
values.append(data['valid_until'])
if not updates:
return {"ok": True}
updates.append('updated = CURRENT_TIMESTAMP')
values.append(grant_id)
cur.execute(
f"UPDATE access_grants SET {', '.join(updates)} WHERE id = %s",
values
)
conn.commit()
return {"ok": True}
@router.delete("/{grant_id}")
def revoke_access_grant(grant_id: str, session: dict = Depends(require_admin)):
"""Admin: Revoke access grant (hard delete)."""
with get_db() as conn:
cur = get_cursor(conn)
# Get grant info for logging
cur.execute("SELECT profile_id, tier_id FROM access_grants WHERE id = %s", (grant_id,))
grant = cur.fetchone()
if grant:
# Log revocation
cur.execute("""
INSERT INTO user_activity_log (profile_id, action, details)
VALUES (%s, 'access_grant_revoked', %s)
""", (
grant['profile_id'],
f'{{"grant_id": "{grant_id}", "tier": "{grant["tier_id"]}"}}'
))
# Delete grant
cur.execute("DELETE FROM access_grants WHERE id = %s", (grant_id,))
conn.commit()
return {"ok": True}

282
backend/routers/coupons.py Normal file
View 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
View File

@ -0,0 +1,121 @@
"""
Feature Management Endpoints for Mitai Jinkendo
Admin-only CRUD for features registry.
"""
from fastapi import APIRouter, HTTPException, Depends
from db import get_db, get_cursor, r2d
from auth import require_admin
router = APIRouter(prefix="/api/features", tags=["features"])
@router.get("")
def list_features(session: dict = Depends(require_admin)):
"""Admin: List all features."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT * FROM features
ORDER BY category, name
""")
return [r2d(r) for r in cur.fetchall()]
@router.post("")
def create_feature(data: dict, session: dict = Depends(require_admin)):
"""
Admin: Create new feature.
Required fields:
- id: Feature ID (e.g., 'new_data_source')
- name: Display name
- category: 'data', 'ai', 'export', 'integration'
- limit_type: 'count' or 'boolean'
- reset_period: 'never', 'daily', 'monthly'
- default_limit: INT or NULL (unlimited)
"""
feature_id = data.get('id', '').strip()
name = data.get('name', '').strip()
description = data.get('description', '')
category = data.get('category')
limit_type = data.get('limit_type', 'count')
reset_period = data.get('reset_period', 'never')
default_limit = data.get('default_limit')
if not feature_id or not name:
raise HTTPException(400, "ID und Name fehlen")
if category not in ['data', 'ai', 'export', 'integration']:
raise HTTPException(400, "Ungültige Kategorie")
if limit_type not in ['count', 'boolean']:
raise HTTPException(400, "limit_type muss 'count' oder 'boolean' sein")
if reset_period not in ['never', 'daily', 'monthly']:
raise HTTPException(400, "Ungültiger reset_period")
with get_db() as conn:
cur = get_cursor(conn)
# Check if ID already exists
cur.execute("SELECT id FROM features WHERE id = %s", (feature_id,))
if cur.fetchone():
raise HTTPException(400, f"Feature '{feature_id}' existiert bereits")
# Create feature
cur.execute("""
INSERT INTO features (
id, name, description, category, limit_type, reset_period, default_limit
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (feature_id, name, description, category, limit_type, reset_period, default_limit))
conn.commit()
return {"ok": True, "id": feature_id}
@router.put("/{feature_id}")
def update_feature(feature_id: str, data: dict, session: dict = Depends(require_admin)):
"""Admin: Update feature."""
with get_db() as conn:
cur = get_cursor(conn)
updates = []
values = []
if 'name' in data:
updates.append('name = %s')
values.append(data['name'])
if 'description' in data:
updates.append('description = %s')
values.append(data['description'])
if 'default_limit' in data:
updates.append('default_limit = %s')
values.append(data['default_limit'])
if 'active' in data:
updates.append('active = %s')
values.append(data['active'])
if not updates:
return {"ok": True}
updates.append('updated = CURRENT_TIMESTAMP')
values.append(feature_id)
cur.execute(
f"UPDATE features SET {', '.join(updates)} WHERE id = %s",
values
)
conn.commit()
return {"ok": True}
@router.delete("/{feature_id}")
def delete_feature(feature_id: str, session: dict = Depends(require_admin)):
"""Admin: Delete feature (soft-delete: set active=false)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,))
conn.commit()
return {"ok": True}

View File

@ -0,0 +1,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
}

View File

@ -0,0 +1,158 @@
"""
Tier Limits Management Endpoints for Mitai Jinkendo
Admin-only matrix editor for Tier x Feature limits.
"""
from fastapi import APIRouter, HTTPException, Depends
from db import get_db, get_cursor, r2d
from auth import require_admin
router = APIRouter(prefix="/api/tier-limits", tags=["tier-limits"])
@router.get("")
def get_tier_limits_matrix(session: dict = Depends(require_admin)):
"""
Admin: Get complete Tier x Feature matrix.
Returns:
{
"tiers": [{id, name}, ...],
"features": [{id, name, category}, ...],
"limits": {
"tier_id:feature_id": limit_value,
...
}
}
"""
with get_db() as conn:
cur = get_cursor(conn)
# Get all tiers
cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true ORDER BY sort_order")
tiers = [r2d(r) for r in cur.fetchall()]
# Get all features
cur.execute("""
SELECT id, name, category, limit_type, default_limit
FROM features
WHERE active = true
ORDER BY category, name
""")
features = [r2d(r) for r in cur.fetchall()]
# Get all tier_limits
cur.execute("SELECT tier_id, feature_id, limit_value FROM tier_limits")
limits = {}
for row in cur.fetchall():
key = f"{row['tier_id']}:{row['feature_id']}"
limits[key] = row['limit_value']
return {
"tiers": tiers,
"features": features,
"limits": limits
}
@router.put("")
def update_tier_limit(data: dict, session: dict = Depends(require_admin)):
"""
Admin: Update single tier limit.
Body:
{
"tier_id": "free",
"feature_id": "weight_entries",
"limit_value": 30 // NULL = unlimited, 0 = disabled
}
"""
tier_id = data.get('tier_id')
feature_id = data.get('feature_id')
limit_value = data.get('limit_value') # Can be None (NULL)
if not tier_id or not feature_id:
raise HTTPException(400, "tier_id und feature_id fehlen")
with get_db() as conn:
cur = get_cursor(conn)
# Upsert tier_limit
cur.execute("""
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES (%s, %s, %s)
ON CONFLICT (tier_id, feature_id)
DO UPDATE SET
limit_value = EXCLUDED.limit_value,
updated = CURRENT_TIMESTAMP
""", (tier_id, feature_id, limit_value))
conn.commit()
return {"ok": True}
@router.put("/batch")
def update_tier_limits_batch(data: dict, session: dict = Depends(require_admin)):
"""
Admin: Batch update multiple tier limits.
Body:
{
"updates": [
{"tier_id": "free", "feature_id": "weight_entries", "limit_value": 30},
{"tier_id": "free", "feature_id": "ai_calls", "limit_value": 0},
...
]
}
"""
updates = data.get('updates', [])
if not updates:
raise HTTPException(400, "updates array fehlt")
with get_db() as conn:
cur = get_cursor(conn)
for update in updates:
tier_id = update.get('tier_id')
feature_id = update.get('feature_id')
limit_value = update.get('limit_value')
if not tier_id or not feature_id:
continue # Skip invalid entries
cur.execute("""
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES (%s, %s, %s)
ON CONFLICT (tier_id, feature_id)
DO UPDATE SET
limit_value = EXCLUDED.limit_value,
updated = CURRENT_TIMESTAMP
""", (tier_id, feature_id, limit_value))
conn.commit()
return {"ok": True, "updated": len(updates)}
@router.delete("")
def delete_tier_limit(tier_id: str, feature_id: str, session: dict = Depends(require_admin)):
"""
Admin: Delete tier limit (falls back to feature default).
Query params: ?tier_id=...&feature_id=...
"""
if not tier_id or not feature_id:
raise HTTPException(400, "tier_id und feature_id fehlen")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
DELETE FROM tier_limits
WHERE tier_id = %s AND feature_id = %s
""", (tier_id, feature_id))
conn.commit()
return {"ok": True}

View File

@ -0,0 +1,117 @@
"""
Tier Management Endpoints for Mitai Jinkendo
Admin-only CRUD for subscription tiers.
"""
from fastapi import APIRouter, HTTPException, Depends
from db import get_db, get_cursor, r2d
from auth import require_admin
router = APIRouter(prefix="/api/tiers", tags=["tiers"])
@router.get("")
def list_tiers(session: dict = Depends(require_admin)):
"""Admin: List all tiers."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT * FROM tiers
ORDER BY sort_order
""")
return [r2d(r) for r in cur.fetchall()]
@router.post("")
def create_tier(data: dict, session: dict = Depends(require_admin)):
"""
Admin: Create new tier.
Required fields:
- id: Tier ID (e.g., 'enterprise')
- name: Display name
- price_monthly_cents, price_yearly_cents: Prices (NULL for free tiers)
"""
tier_id = data.get('id', '').strip()
name = data.get('name', '').strip()
description = data.get('description', '')
price_monthly_cents = data.get('price_monthly_cents')
price_yearly_cents = data.get('price_yearly_cents')
sort_order = data.get('sort_order', 99)
if not tier_id or not name:
raise HTTPException(400, "ID und Name fehlen")
with get_db() as conn:
cur = get_cursor(conn)
# Check if ID already exists
cur.execute("SELECT id FROM tiers WHERE id = %s", (tier_id,))
if cur.fetchone():
raise HTTPException(400, f"Tier '{tier_id}' existiert bereits")
# Create tier
cur.execute("""
INSERT INTO tiers (
id, name, description, price_monthly_cents, price_yearly_cents, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s)
""", (tier_id, name, description, price_monthly_cents, price_yearly_cents, sort_order))
conn.commit()
return {"ok": True, "id": tier_id}
@router.put("/{tier_id}")
def update_tier(tier_id: str, data: dict, session: dict = Depends(require_admin)):
"""Admin: Update tier."""
with get_db() as conn:
cur = get_cursor(conn)
updates = []
values = []
if 'name' in data:
updates.append('name = %s')
values.append(data['name'])
if 'description' in data:
updates.append('description = %s')
values.append(data['description'])
if 'price_monthly_cents' in data:
updates.append('price_monthly_cents = %s')
values.append(data['price_monthly_cents'])
if 'price_yearly_cents' in data:
updates.append('price_yearly_cents = %s')
values.append(data['price_yearly_cents'])
if 'active' in data:
updates.append('active = %s')
values.append(data['active'])
if 'sort_order' in data:
updates.append('sort_order = %s')
values.append(data['sort_order'])
if not updates:
return {"ok": True}
updates.append('updated = CURRENT_TIMESTAMP')
values.append(tier_id)
cur.execute(
f"UPDATE tiers SET {', '.join(updates)} WHERE id = %s",
values
)
conn.commit()
return {"ok": True}
@router.delete("/{tier_id}")
def delete_tier(tier_id: str, session: dict = Depends(require_admin)):
"""Admin: Delete tier (soft-delete: set active=false)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("UPDATE tiers SET active = false WHERE id = %s", (tier_id,))
conn.commit()
return {"ok": True}

View File

@ -0,0 +1,140 @@
"""
User Restrictions Management Endpoints for Mitai Jinkendo
Admin-only user-specific feature overrides.
"""
from fastapi import APIRouter, HTTPException, Depends
from db import get_db, get_cursor, r2d
from auth import require_admin
router = APIRouter(prefix="/api/user-restrictions", tags=["user-restrictions"])
@router.get("")
def list_user_restrictions(profile_id: str = None, session: dict = Depends(require_admin)):
"""
Admin: List user restrictions.
Optional query param: ?profile_id=... (filter by user)
"""
with get_db() as conn:
cur = get_cursor(conn)
if profile_id:
cur.execute("""
SELECT
ur.*,
f.name as feature_name,
f.category as feature_category,
p.name as profile_name
FROM user_feature_restrictions ur
JOIN features f ON f.id = ur.feature_id
JOIN profiles p ON p.id = ur.profile_id
WHERE ur.profile_id = %s
ORDER BY f.category, f.name
""", (profile_id,))
else:
cur.execute("""
SELECT
ur.*,
f.name as feature_name,
f.category as feature_category,
p.name as profile_name,
p.email as profile_email
FROM user_feature_restrictions ur
JOIN features f ON f.id = ur.feature_id
JOIN profiles p ON p.id = ur.profile_id
ORDER BY p.name, f.category, f.name
""")
return [r2d(r) for r in cur.fetchall()]
@router.post("")
def create_user_restriction(data: dict, session: dict = Depends(require_admin)):
"""
Admin: Create user-specific feature restriction.
Body:
{
"profile_id": "uuid",
"feature_id": "weight_entries",
"limit_value": 10, // NULL = unlimited, 0 = disabled
"reason": "Spam prevention"
}
"""
profile_id = data.get('profile_id')
feature_id = data.get('feature_id')
limit_value = data.get('limit_value')
reason = data.get('reason', '')
if not profile_id or not feature_id:
raise HTTPException(400, "profile_id und feature_id fehlen")
with get_db() as conn:
cur = get_cursor(conn)
# Check if restriction already exists
cur.execute("""
SELECT id FROM user_feature_restrictions
WHERE profile_id = %s AND feature_id = %s
""", (profile_id, feature_id))
if cur.fetchone():
raise HTTPException(400, "Einschränkung existiert bereits (nutze PUT zum Aktualisieren)")
# Create restriction
cur.execute("""
INSERT INTO user_feature_restrictions (
profile_id, feature_id, limit_value, reason, created_by
)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""", (profile_id, feature_id, limit_value, reason, session['profile_id']))
restriction_id = cur.fetchone()['id']
conn.commit()
return {"ok": True, "id": restriction_id}
@router.put("/{restriction_id}")
def update_user_restriction(restriction_id: str, data: dict, session: dict = Depends(require_admin)):
"""Admin: Update user restriction."""
with get_db() as conn:
cur = get_cursor(conn)
updates = []
values = []
if 'limit_value' in data:
updates.append('limit_value = %s')
values.append(data['limit_value'])
if 'reason' in data:
updates.append('reason = %s')
values.append(data['reason'])
if not updates:
return {"ok": True}
updates.append('updated = CURRENT_TIMESTAMP')
values.append(restriction_id)
cur.execute(
f"UPDATE user_feature_restrictions SET {', '.join(updates)} WHERE id = %s",
values
)
conn.commit()
return {"ok": True}
@router.delete("/{restriction_id}")
def delete_user_restriction(restriction_id: str, session: dict = Depends(require_admin)):
"""Admin: Delete user restriction (reverts to tier limit)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM user_feature_restrictions WHERE id = %s", (restriction_id,))
conn.commit()
return {"ok": True}