Merge pull request '9c datatables' (#5) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Reviewed-on: #5
This commit is contained in:
Lars 2026-03-19 13:00:31 +01:00
commit 9387670a7b
4 changed files with 932 additions and 7 deletions

461
CLAUDE.md
View File

@ -96,19 +96,468 @@ mitai-jinkendo/
- ✅ Automatische SQLite→PostgreSQL Migration bei Container-Start - ✅ Automatische SQLite→PostgreSQL Migration bei Container-Start
- ✅ **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: ### Was in v9c kommt: Subscription & Coupon Management System
- 🔲 Selbst-Registrierung mit E-Mail-Bestätigung **Core Features:**
- 🔲 Freemium Tier-System (free/basic/premium/selfhosted) - 🔲 Selbst-Registrierung mit E-Mail-Verifizierung (Pflicht)
- 🔲 14-Tage Trial automatisch - 🔲 Flexibles Tier-System (free/basic/premium/selfhosted) - Admin-editierbar
- 🔲 Einladungslinks für Beta-Nutzer - 🔲 Trial-System (Dauer konfigurierbar, auto-start nach E-Mail-Verifikation)
- 🔲 Admin kann Tiers manuell setzen - 🔲 **Coupon-System** (2 Typen):
- Single-Use Coupons (Geschenke, zeitlich begrenzt)
- Multi-Use Period Coupons (z.B. Wellpass, monatlich erneuerbar)
- 🔲 Coupon-Stacking-Logik (Pause + Resume bei Wellpass-Override)
- 🔲 Access-Grant-System (zeitlich begrenzte Zugriffe mit Quelle-Tracking)
- 🔲 User-Activity-Log (Login, Password-Änderungen, Coupon-Einlösungen, etc.)
- 🔲 User-Stats (Login-Streaks, Nutzungsstatistiken)
- 🔲 Individuelle User-Restrictions (Admin kann Limits pro User setzen)
- 🔲 App-Settings (globale Konfiguration durch Admin)
- 🔲 Erweiterte Admin-User-Verwaltung (Activity-Log, Stats, Access-Historie)
**E-Mail Templates (v9c):**
- 🔲 Registrierung + E-Mail-Verifizierung
- 🔲 Einladungslink
- 🔲 Passwort-Reset (bereits vorhanden)
**Spätere Features (v9d/v9e):**
- 🔲 Bonus-System (Login-Streaks → Punkte → Geschenk-Coupons)
- 🔲 Trial-Reminder-E-Mails (3 Tage vor Ablauf)
- 🔲 Monatliches Nutzungs-Summary per E-Mail
- 🔲 Self-Service Upgrade (Stripe-Integration)
- 🔲 Partner-Verwaltung (Wellpass, Hansefit, etc.)
- 🔲 Admin-Benachrichtigungen (neue Registrierungen, etc.)
### Was in v9d kommt: ### Was in v9d kommt:
- 🔲 Bonus-System & Gamification (Streaks, Achievements)
- 🔲 Stripe-Integration (Self-Service Upgrade, Subscriptions)
- 🔲 OAuth2-Grundgerüst für Fitness-Connectoren - 🔲 OAuth2-Grundgerüst für Fitness-Connectoren
- 🔲 Strava Connector - 🔲 Strava Connector
- 🔲 Withings Connector (Waage) - 🔲 Withings Connector (Waage)
- 🔲 Garmin Connector - 🔲 Garmin Connector
---
## v9c Architektur-Details: Subscription & Coupon System
### Datenbank-Schema (Neue Tabellen)
#### **app_settings** - Globale Konfiguration
```sql
key, value, value_type, description, updated_at, updated_by
Beispiele:
- trial_days: 14
- trial_behavior: 'downgrade' | 'lock'
- allow_registration: true/false
- default_tier_trial: 'premium'
- gift_coupons_per_month: 3
```
#### **tiers** - Tier-Konfiguration (vereinfacht)
```sql
id, slug, name, description, sort_order, active
Initial Tiers:
- free, basic, premium, selfhosted
Limits sind jetzt in tier_limits Tabelle (siehe unten)!
```
#### **features** - Feature-Registry (alle limitierbaren Features)
```sql
id, slug, name, category, description, unit
default_limit (NULL = unbegrenzt)
reset_period ('monthly' | 'daily' | 'never')
visible_in_admin, sort_order, active
Initial Features:
- weight_entries: Gewichtseinträge, default: 30, never
- circumference_entries: Umfangsmessungen, default: 30, never
- caliper_entries: Caliper-Messungen, default: 30, never
- nutrition_entries: Ernährungseinträge, default: 30, never
- activity_entries: Aktivitäten, default: 30, never
- photos: Progress-Fotos, default: 5, never
- ai_calls: KI-Analysen, default: 0, monthly
- ai_pipeline: KI-Pipeline, default: 0, monthly
- csv_import: CSV-Importe, default: 0, monthly
- data_export: Daten-Exporte, default: 0, monthly
- fitness_connectors: Fitness-Connectoren, default: 0, never
Neue Features einfach per INSERT hinzufügen - kein Schema-Change!
```
#### **tier_limits** - Limits pro Tier + Feature
```sql
id, tier_slug, feature_slug, limit_value, enabled
Beispiel Free Tier:
- ('free', 'weight_entries', 30, true)
- ('free', 'ai_calls', 0, false) -- KI deaktiviert
- ('free', 'data_export', 0, false)
Beispiel Premium:
- ('premium', 'weight_entries', NULL, true) -- unbegrenzt
- ('premium', 'ai_calls', NULL, true) -- unbegrenzt
Admin kann in UI Matrix bearbeiten: Tier x Feature
```
#### **user_feature_restrictions** - Individuelle User-Limits
```sql
id, profile_id, feature_slug, limit_value, enabled
reason, set_by (admin_id)
Überschreibt Tier-Limits für spezifische User.
Admin kann jeden User individuell einschränken oder erweitern.
```
#### **user_feature_usage** - Nutzungs-Tracking
```sql
id, profile_id, feature_slug, period_start, usage_count, last_used
Für Features mit reset_period (z.B. ai_calls monthly).
Wird automatisch zurückgesetzt am Monatsanfang.
```
#### **coupons** - Coupon-Verwaltung
```sql
code, type ('single_use' | 'multi_use_period' | 'gift')
valid_from, valid_until, grants_tier, duration_days
max_redemptions, current_redemptions
created_by, created_for, notes, active
Beispiel Single-Use:
Code: FRIEND-GIFT-XYZ, 30 Tage Premium, max 1x
Beispiel Multi-Use Period:
Code: WELLPASS-2026-03, gültig 01.03-31.03, unbegrenzte Einlösungen
```
#### **coupon_redemptions** - Einlösungs-Historie
```sql
coupon_id, profile_id, redeemed_at, access_grant_id
UNIQUE(coupon_id, profile_id) - User kann denselben Coupon nur 1x einlösen
```
#### **access_grants** - Zeitlich begrenzte Zugriffe
```sql
profile_id, granted_tier, valid_from, valid_until
source ('coupon' | 'admin_grant' | 'trial')
active (false wenn pausiert durch Wellpass-Override)
paused_at, paused_by (access_grant_id das pausiert hat)
Stacking-Logik:
- Multi-Use Period Coupon (Wellpass): pausiert andere grants
- Single-Use Coupon: stackt zeitlich (Resume nach Ablauf)
```
#### **user_activity_log** - Aktivitäts-Tracking
```sql
profile_id, activity_type, details (JSONB), ip_address, user_agent, created
Activity Types:
- login, password_change, email_change, coupon_redeemed
- tier_change, export, ai_analysis, registration
```
#### **user_stats** - Aggregierte Statistiken
```sql
profile_id, first_login, last_login, total_logins
current_streak_days, longest_streak_days, last_streak_date
total_weight_entries, total_ai_analyses, total_exports
bonus_points (später), gift_coupons_available (später)
```
#### **profiles** - Erweiterte Spalten
```sql
tier, tier_locked (Admin kann Tier festnageln)
trial_ends_at, email_verified, email_verify_token
invited_by, contract_type, contract_valid_until
stripe_customer_id (vorbereitet für v9d)
```
---
### Backend-Erweiterungen
#### Neue Router (v9c):
```
routers/tiers.py - Tier-Verwaltung (List, Edit, Create)
routers/features.py - Feature-Registry (List, Add, Edit, Delete) ⭐ NEU
routers/tier_limits.py - Tier-Limits-Matrix (Admin bearbeitet Tier x Feature) ⭐ NEU
routers/coupons.py - Coupon-System (Redeem, Admin CRUD)
routers/access_grants.py - Zugriffs-Verwaltung (Current, Grant, Revoke)
routers/user_admin.py - Erweiterte User-Verwaltung (Activity, Stats, Feature-Restrictions)
routers/settings.py - App-Einstellungen (Admin)
routers/registration.py - Registrierung + E-Mail-Verifizierung
```
#### Neue Middleware:
```python
check_feature_access(profile_id, feature_slug, action='use')
"""
Zentrale Feature-Access-Prüfung.
Hierarchie:
1. User-Restriction (höchste Priorität)
2. Tier-Limit
3. Feature-Default
Returns: {'allowed': bool, 'limit': int, 'used': int, 'remaining': int, 'reason': str}
"""
increment_feature_usage(profile_id, feature_slug)
"""
Inkrementiert Nutzungszähler.
Berücksichtigt reset_period (monthly, daily, never).
"""
log_activity(profile_id, activity_type, details=None)
"""
Loggt User-Aktivitäten in user_activity_log.
"""
```
#### Hintergrund-Tasks (Cron):
```python
check_expired_access() # Täglich 00:00 - Trial/Coupon-Ablauf prüfen
reset_monthly_limits() # 1. jeden Monats - AI-Calls zurücksetzen
update_user_streaks() # Täglich 23:59 - Login-Streaks aktualisieren
```
---
### Zugriffs-Hierarchie
```
Effektiver Tier wird ermittelt durch (Priorität absteigend):
1. Admin-Override (tier_locked=true) → nutzt profiles.tier
2. Aktiver access_grant (nicht pausiert, valid_until > now)
3. Trial (trial_ends_at > now)
4. Base tier (profiles.tier)
Wellpass-Override-Logik:
- User hat Single-Use Coupon (20 Tage verbleibend)
- User löst Wellpass-Coupon ein (gültig bis 31.03)
- Single-Use access_grant wird pausiert (active=false, paused_by=wellpass_grant_id)
- Nach Wellpass-Ablauf: Single-Use wird reaktiviert (noch 20 Tage)
```
---
### Tier-Limits & Feature-Gates
**Daten-Sichtbarkeit bei Downgrade:**
- Frontend: Buttons/Features ausblenden (Export, KI, Import)
- Backend: API limitiert Rückgabe (z.B. nur letzte 30 Gewichtseinträge bei free)
- Daten bleiben erhalten, werden nur versteckt
- Bei Upgrade wieder sichtbar
**Feature-Checks:**
```python
# Beispiel: Gewicht-Eintrag erstellen
@check_feature_limit('weight', 'create')
def create_weight_entry():
# Prüft: Hat User max_weight_entries erreicht?
# Falls ja: HTTPException 403 "Limit erreicht - Upgrade erforderlich"
```
---
### Frontend-Erweiterungen
#### Neue Seiten:
```
RegisterPage.jsx - Registrierung (Name, E-Mail, Passwort)
VerifyEmailPage.jsx - E-Mail-Verifizierung (Token aus URL)
RedeemCouponPage.jsx - Coupon-Eingabe (oder Modal)
AdminCouponsPage.jsx - Coupon-Verwaltung (Admin)
AdminTiersPage.jsx - Tier-Verwaltung (CRUD) (Admin)
AdminFeaturesPage.jsx - Feature-Registry (List, Add, Edit) ⭐ NEU
AdminTierLimitsPage.jsx - Tier x Feature Matrix (bearbeiten) ⭐ NEU
AdminUserRestrictionsPage.jsx - User-spezifische Limits (bearbeiten) ⭐ NEU
AdminSettingsPage.jsx - App-Einstellungen (Admin)
```
#### Neue Komponenten:
```jsx
<TierBadge tier="premium" /> // Tier-Anzeige mit Icon
<FeatureGate feature="ai_calls">...</> // Feature-basierte Sichtbarkeit ⭐ GEÄNDERT
<AccessStatus /> // "Trial endet in 5 Tagen" Banner
<CouponInput onRedeem={...} /> // Coupon-Eingabefeld
<ActivityTimeline activities={...} /> // User-Activity-Log
<FeatureLimitBadge feature="ai_calls" /> // "5/10 verwendet" Anzeige ⭐ NEU
<TierLimitsMatrix tiers={...} features={...}/> // Matrix-Editor ⭐ NEU
<StreakCounter days={7} /> // Login-Streak (später)
```
#### Erweiterte Admin-Seiten:
```
AdminUsersPage.jsx erweitert um:
- Activity-Log Button → zeigt user_activity_log
- Stats Button → zeigt user_stats
- Access-Grants Button → zeigt aktive/abgelaufene Zugriffe
- Feature-Restrictions Button → individuelle Feature-Limits setzen ⭐ GEÄNDERT
- Grant Access Button → manuell Tier-Zugriff gewähren
- Usage-Overview → zeigt user_feature_usage für alle Features ⭐ NEU
```
#### Admin-Interface-Details:
**AdminFeaturesPage.jsx** - Feature-Registry verwalten
```jsx
// Alle Features auflisten + neue hinzufügen
<FeatureList>
{features.map(f => (
<FeatureRow>
<Name>{f.name}</Name>
<Category>{f.category}</Category>
<Unit>{f.unit}</Unit>
<ResetPeriod>{f.reset_period}</ResetPeriod>
<DefaultLimit>{f.default_limit ?? '∞'}</DefaultLimit>
<Actions>
<EditButton />
<DeleteButton />
</Actions>
</FeatureRow>
))}
</FeatureList>
<AddFeatureButton />
```
**AdminTierLimitsPage.jsx** - Matrix-Editor
```jsx
// Matrix-View: Tiers (Spalten) x Features (Zeilen)
<TierLimitsMatrix>
<thead>
<tr>
<th>Feature</th>
<th>Free</th>
<th>Basic</th>
<th>Premium</th>
<th>Selfhosted</th>
</tr>
</thead>
<tbody>
<tr>
<td>Gewichtseinträge</td>
<td><Input value="30" /></td>
<td><Input value="" placeholder="∞" /></td>
<td><Input value="" placeholder="∞" /></td>
<td><Input value="" placeholder="∞" /></td>
</tr>
<tr>
<td>KI-Analysen/Monat</td>
<td><Checkbox disabled /> 0</td>
<td><Checkbox checked /> <Input value="10" /></td>
<td><Checkbox checked /></td>
<td><Checkbox checked /></td>
</tr>
</tbody>
</TierLimitsMatrix>
```
**AdminUserRestrictionsPage.jsx** - Individuelle User-Limits
```jsx
<UserSelect onChange={loadUser} />
<CurrentTier badge={user.tier} />
<CurrentUsage>
{features.map(f => (
<FeatureUsageRow key={f.slug}>
<Name>{f.name}</Name>
<TierLimit>{getTierLimit(user.tier, f.slug)}</TierLimit>
<CurrentUsage>{getUsage(user.id, f.slug)}</CurrentUsage>
<Override>
<Input
value={getUserRestriction(user.id, f.slug)}
placeholder="Tier-Standard"
/>
</Override>
<SaveButton />
</FeatureUsageRow>
))}
</CurrentUsage>
```
---
### E-Mail Templates (v9c)
**1. Registrierung + E-Mail-Verifizierung:**
```
Betreff: Willkommen bei Mitai Jinkendo - E-Mail bestätigen
Hallo {name},
vielen Dank für deine Registrierung bei Mitai Jinkendo!
Bitte bestätige deine E-Mail-Adresse:
{app_url}/verify-email?token={token}
Nach der Bestätigung startet dein 14-Tage Premium Trial automatisch.
Viel Erfolg bei deinem Training!
Dein Mitai Jinkendo Team
```
**2. Einladungslink (Admin):**
```
Betreff: Du wurdest zu Mitai Jinkendo eingeladen
Hallo,
{admin_name} hat dich zu Mitai Jinkendo eingeladen!
Registriere dich jetzt:
{app_url}/register?invite={token}
Du erhältst {tier} Zugriff.
Dein Mitai Jinkendo Team
```
---
### Migrations-Reihenfolge (v9c)
```
Phase 1 - DB Schema:
1. app_settings Tabelle + Initialdaten
2. tiers Tabelle + 4 Standard-Tiers
3. coupons Tabelle
4. coupon_redemptions Tabelle
5. access_grants Tabelle
6. user_activity_log Tabelle
7. user_stats Tabelle
8. user_restrictions Tabelle
9. profiles Spalten erweitern
10. Bestehende Profile migrieren (Lars → tier='selfhosted', email_verified=true)
Phase 2 - Backend:
11. Tier-System Router + Middleware
12. Registrierungs-Flow
13. Coupon-System
14. Access-Grant-Logik
15. Activity-Logging
16. Erweiterte Admin-Endpoints
Phase 3 - Frontend:
17. Registrierungs-Seiten
18. Tier-System UI-Komponenten
19. Coupon-Eingabe
20. Erweiterte Admin-Panels
21. Feature-Gates in bestehende Seiten einbauen
Phase 4 - Cron-Jobs:
22. Expired-Access-Checker
23. Monthly-Reset
24. Streak-Updater
Phase 5 - Testing & Deployment:
25. Dev-Testing
26. Prod-Deployment
```
---
## Deployment ## Deployment
### Infrastruktur ### Infrastruktur

View File

@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Apply v9c Subscription System Migration
This script checks if v9c migration is needed and applies it.
Run automatically on container startup via main.py startup event.
"""
import os
import psycopg2
from psycopg2.extras import RealDictCursor
def get_db_connection():
"""Get PostgreSQL connection."""
return psycopg2.connect(
host=os.getenv("DB_HOST", "postgres"),
port=int(os.getenv("DB_PORT", 5432)),
database=os.getenv("DB_NAME", "mitai_prod"),
user=os.getenv("DB_USER", "mitai_prod"),
password=os.getenv("DB_PASSWORD", ""),
cursor_factory=RealDictCursor
)
def migration_needed(conn):
"""Check if v9c migration is needed."""
cur = conn.cursor()
# Check if tiers table exists
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'tiers'
)
""")
tiers_exists = cur.fetchone()['exists']
# Check if features table exists
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'features'
)
""")
features_exists = cur.fetchone()['exists']
cur.close()
# Migration needed if either table is missing
return not (tiers_exists and features_exists)
def apply_migration():
"""Apply v9c migration if needed."""
print("[v9c Migration] Checking if migration is needed...")
try:
conn = get_db_connection()
if not migration_needed(conn):
print("[v9c Migration] Already applied, skipping.")
conn.close()
return
print("[v9c Migration] Applying subscription system migration...")
# Read migration SQL
migration_path = os.path.join(
os.path.dirname(__file__),
"migrations",
"v9c_subscription_system.sql"
)
with open(migration_path, 'r', encoding='utf-8') as f:
migration_sql = f.read()
# Execute migration
cur = conn.cursor()
cur.execute(migration_sql)
conn.commit()
cur.close()
conn.close()
print("[v9c Migration] ✅ Migration completed successfully!")
# Verify tables created
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('tiers', 'features', 'tier_limits', 'access_grants', 'coupons')
ORDER BY table_name
""")
tables = [r['table_name'] for r in cur.fetchall()]
print(f"[v9c Migration] Created tables: {', '.join(tables)}")
# Verify initial data
cur.execute("SELECT COUNT(*) as count FROM tiers")
tier_count = cur.fetchone()['count']
cur.execute("SELECT COUNT(*) as count FROM features")
feature_count = cur.fetchone()['count']
cur.execute("SELECT COUNT(*) as count FROM tier_limits")
limit_count = cur.fetchone()['count']
print(f"[v9c Migration] Initial data: {tier_count} tiers, {feature_count} features, {limit_count} tier limits")
cur.close()
conn.close()
except Exception as e:
print(f"[v9c Migration] ❌ Error: {e}")
raise
if __name__ == "__main__":
apply_migration()

View File

@ -51,6 +51,13 @@ async def startup_event():
print(f"⚠️ init_db() failed (non-fatal): {e}") print(f"⚠️ init_db() failed (non-fatal): {e}")
# Don't crash on startup - can be created manually # Don't crash on startup - can be created manually
# Apply v9c migration if needed
try:
from apply_v9c_migration import apply_migration
apply_migration()
except Exception as e:
print(f"⚠️ v9c migration failed (non-fatal): {e}")
# ── Register Routers ────────────────────────────────────────────────────────── # ── Register Routers ──────────────────────────────────────────────────────────
app.include_router(auth.router) # /api/auth/* app.include_router(auth.router) # /api/auth/*
app.include_router(profiles.router) # /api/profiles/*, /api/profile app.include_router(profiles.router) # /api/profiles/*, /api/profile
@ -71,4 +78,4 @@ app.include_router(importdata.router) # /api/import/*
@app.get("/") @app.get("/")
def root(): def root():
"""API health check.""" """API health check."""
return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"} return {"status": "ok", "service": "mitai-jinkendo", "version": "v9c-dev"}

View File

@ -0,0 +1,352 @@
-- ============================================================================
-- Mitai Jinkendo v9c: Subscription & Coupon System Migration
-- ============================================================================
-- Created: 2026-03-19
-- Purpose: Add flexible tier system with Feature-Registry Pattern
--
-- Tables added:
-- 1. app_settings - Global configuration
-- 2. tiers - Subscription tiers (simplified)
-- 3. features - Feature registry (all limitable features)
-- 4. tier_limits - Tier x Feature matrix
-- 5. user_feature_restrictions - Individual user overrides
-- 6. user_feature_usage - Usage tracking
-- 7. coupons - Coupon management
-- 8. coupon_redemptions - Redemption history
-- 9. access_grants - Time-limited access grants
-- 10. user_activity_log - Activity tracking
-- 11. user_stats - Aggregated statistics
--
-- Feature-Registry Pattern:
-- Instead of hardcoded columns (max_weight_entries, max_ai_calls),
-- all limits are defined in features table and configured via tier_limits.
-- This allows adding new limitable features without schema changes.
-- ============================================================================
-- ============================================================================
-- 1. app_settings - Global configuration
-- ============================================================================
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- 2. tiers - Subscription tiers (simplified)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tiers (
id TEXT PRIMARY KEY, -- 'free', 'basic', 'premium', 'selfhosted'
name TEXT NOT NULL, -- Display name
description TEXT, -- Marketing description
price_monthly_cents INTEGER, -- NULL for free/selfhosted
price_yearly_cents INTEGER, -- NULL for free/selfhosted
stripe_price_id_monthly TEXT, -- Stripe Price ID (for v9d)
stripe_price_id_yearly TEXT, -- Stripe Price ID (for v9d)
active BOOLEAN DEFAULT true, -- Can new users subscribe?
sort_order INTEGER DEFAULT 0,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- 3. features - Feature registry (all limitable features)
-- ============================================================================
CREATE TABLE IF NOT EXISTS features (
id TEXT PRIMARY KEY, -- 'weight_entries', 'ai_calls', 'photos', etc.
name TEXT NOT NULL, -- Display name
description TEXT, -- What is this feature?
category TEXT, -- 'data', 'ai', 'export', 'integration'
limit_type TEXT DEFAULT 'count', -- 'count', 'boolean', 'quota'
reset_period TEXT DEFAULT 'never', -- 'never', 'monthly', 'daily'
default_limit INTEGER, -- Fallback if no tier_limit defined
active BOOLEAN DEFAULT true, -- Is this feature currently used?
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- 4. tier_limits - Tier x Feature matrix
-- ============================================================================
CREATE TABLE IF NOT EXISTS tier_limits (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tier_id TEXT NOT NULL REFERENCES tiers(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
limit_value INTEGER, -- NULL = unlimited, 0 = disabled
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tier_id, feature_id)
);
-- ============================================================================
-- 5. user_feature_restrictions - Individual user overrides
-- ============================================================================
CREATE TABLE IF NOT EXISTS user_feature_restrictions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
limit_value INTEGER, -- NULL = unlimited, 0 = disabled
reason TEXT, -- Why was this override applied?
created_by UUID, -- Admin profile_id
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, feature_id)
);
-- ============================================================================
-- 6. user_feature_usage - Usage tracking
-- ============================================================================
CREATE TABLE IF NOT EXISTS user_feature_usage (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
usage_count INTEGER DEFAULT 0,
reset_at TIMESTAMP, -- When does this counter reset?
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, feature_id)
);
-- ============================================================================
-- 7. coupons - Coupon management
-- ============================================================================
CREATE TABLE IF NOT EXISTS coupons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code TEXT UNIQUE NOT NULL,
type TEXT NOT NULL, -- 'single_use', 'period', 'wellpass'
tier_id TEXT REFERENCES tiers(id) ON DELETE SET NULL,
duration_days INTEGER, -- For period/wellpass coupons
max_redemptions INTEGER, -- NULL = unlimited
redemption_count INTEGER DEFAULT 0,
valid_from TIMESTAMP,
valid_until TIMESTAMP,
active BOOLEAN DEFAULT true,
created_by UUID, -- Admin profile_id
description TEXT, -- Internal note
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- 8. coupon_redemptions - Redemption history
-- ============================================================================
CREATE TABLE IF NOT EXISTS coupon_redemptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
redeemed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
access_grant_id UUID, -- FK to access_grants (created as result)
UNIQUE(coupon_id, profile_id) -- One redemption per user per coupon
);
-- ============================================================================
-- 9. access_grants - Time-limited access grants
-- ============================================================================
CREATE TABLE IF NOT EXISTS access_grants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
tier_id TEXT NOT NULL REFERENCES tiers(id) ON DELETE CASCADE,
granted_by TEXT, -- 'coupon', 'admin', 'trial', 'subscription'
coupon_id UUID REFERENCES coupons(id) ON DELETE SET NULL,
valid_from TIMESTAMP NOT NULL,
valid_until TIMESTAMP NOT NULL,
is_active BOOLEAN DEFAULT true, -- Can be paused by Wellpass logic
paused_by UUID, -- access_grant.id that paused this
paused_at TIMESTAMP, -- When was it paused?
remaining_days INTEGER, -- Days left when paused (for resume)
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- 10. user_activity_log - Activity tracking
-- ============================================================================
CREATE TABLE IF NOT EXISTS user_activity_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
action TEXT NOT NULL, -- 'login', 'logout', 'coupon_redeemed', 'tier_changed'
details JSONB, -- Flexible metadata
ip_address TEXT,
user_agent TEXT,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_activity_log_profile ON user_activity_log(profile_id, created DESC);
CREATE INDEX IF NOT EXISTS idx_activity_log_action ON user_activity_log(action, created DESC);
-- ============================================================================
-- 11. user_stats - Aggregated statistics
-- ============================================================================
CREATE TABLE IF NOT EXISTS user_stats (
profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
last_login TIMESTAMP,
login_count INTEGER DEFAULT 0,
weight_entries_count INTEGER DEFAULT 0,
ai_calls_count INTEGER DEFAULT 0,
photos_count INTEGER DEFAULT 0,
total_data_points INTEGER DEFAULT 0,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- Extend profiles table with subscription fields
-- ============================================================================
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS tier TEXT DEFAULT 'free';
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMP;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS email_verify_token TEXT;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS invited_by UUID REFERENCES profiles(id) ON DELETE SET NULL;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS invitation_token TEXT;
-- ============================================================================
-- Insert initial data
-- ============================================================================
-- App settings
INSERT INTO app_settings (key, value, description) VALUES
('trial_duration_days', '14', 'Default trial duration for new registrations'),
('post_trial_tier', 'free', 'Tier after trial expires (free/disabled)'),
('require_email_verification', 'true', 'Require email verification before activation'),
('self_registration_enabled', 'true', 'Allow self-registration')
ON CONFLICT (key) DO NOTHING;
-- Tiers
INSERT INTO tiers (id, name, description, price_monthly_cents, price_yearly_cents, active, sort_order) VALUES
('free', 'Free', 'Eingeschränkte Basis-Funktionen', NULL, NULL, true, 1),
('basic', 'Basic', 'Kernfunktionen ohne KI', 499, 4990, true, 2),
('premium', 'Premium', 'Alle Features inkl. KI und Connectoren', 999, 9990, true, 3),
('selfhosted', 'Self-Hosted', 'Unbegrenzt (für Heimserver)', NULL, NULL, false, 4)
ON CONFLICT (id) DO NOTHING;
-- Features (11 initial features)
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) VALUES
('weight_entries', 'Gewichtseinträge', 'Anzahl Gewichtsmessungen', 'data', 'count', 'never', NULL, true),
('circumference_entries', 'Umfangs-Einträge', 'Anzahl Umfangsmessungen', 'data', 'count', 'never', NULL, true),
('caliper_entries', 'Caliper-Einträge', 'Anzahl Hautfaltenmessungen', 'data', 'count', 'never', NULL, true),
('nutrition_entries', 'Ernährungs-Einträge', 'Anzahl Ernährungslogs', 'data', 'count', 'never', NULL, true),
('activity_entries', 'Aktivitäts-Einträge', 'Anzahl Trainings/Aktivitäten', 'data', 'count', 'never', NULL, true),
('photos', 'Progress-Fotos', 'Anzahl hochgeladene Fotos', 'data', 'count', 'never', NULL, true),
('ai_calls', 'KI-Analysen', 'KI-Auswertungen pro Monat', 'ai', 'count', 'monthly', 0, true),
('ai_pipeline', 'KI-Pipeline', 'Vollständige Pipeline-Analyse', 'ai', 'boolean', 'never', 0, true),
('export_csv', 'CSV-Export', 'Daten als CSV exportieren', 'export', 'boolean', 'never', 0, true),
('export_json', 'JSON-Export', 'Daten als JSON exportieren', 'export', 'boolean', 'never', 0, true),
('export_zip', 'ZIP-Export', 'Vollständiger Backup-Export', 'export', 'boolean', 'never', 0, true)
ON CONFLICT (id) DO NOTHING;
-- Tier x Feature Matrix (tier_limits)
-- Format: (tier, feature, limit) - NULL = unlimited, 0 = disabled
-- FREE tier (sehr eingeschränkt)
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('free', 'weight_entries', 30),
('free', 'circumference_entries', 10),
('free', 'caliper_entries', 10),
('free', 'nutrition_entries', 30),
('free', 'activity_entries', 30),
('free', 'photos', 5),
('free', 'ai_calls', 0), -- Keine KI
('free', 'ai_pipeline', 0), -- Keine Pipeline
('free', 'export_csv', 0), -- Kein Export
('free', 'export_json', 0),
('free', 'export_zip', 0)
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- BASIC tier (Kernfunktionen)
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('basic', 'weight_entries', NULL), -- Unbegrenzt
('basic', 'circumference_entries', NULL),
('basic', 'caliper_entries', NULL),
('basic', 'nutrition_entries', NULL),
('basic', 'activity_entries', NULL),
('basic', 'photos', 50),
('basic', 'ai_calls', 3), -- 3 KI-Calls/Monat
('basic', 'ai_pipeline', 0), -- Keine Pipeline
('basic', 'export_csv', 1), -- Export erlaubt
('basic', 'export_json', 1),
('basic', 'export_zip', 1)
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- PREMIUM tier (alles unbegrenzt)
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('premium', 'weight_entries', NULL),
('premium', 'circumference_entries', NULL),
('premium', 'caliper_entries', NULL),
('premium', 'nutrition_entries', NULL),
('premium', 'activity_entries', NULL),
('premium', 'photos', NULL),
('premium', 'ai_calls', NULL), -- Unbegrenzt KI
('premium', 'ai_pipeline', 1), -- Pipeline erlaubt
('premium', 'export_csv', 1),
('premium', 'export_json', 1),
('premium', 'export_zip', 1)
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- SELFHOSTED tier (alles unbegrenzt)
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('selfhosted', 'weight_entries', NULL),
('selfhosted', 'circumference_entries', NULL),
('selfhosted', 'caliper_entries', NULL),
('selfhosted', 'nutrition_entries', NULL),
('selfhosted', 'activity_entries', NULL),
('selfhosted', 'photos', NULL),
('selfhosted', 'ai_calls', NULL),
('selfhosted', 'ai_pipeline', 1),
('selfhosted', 'export_csv', 1),
('selfhosted', 'export_json', 1),
('selfhosted', 'export_zip', 1)
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- ============================================================================
-- Migrate existing profiles
-- ============================================================================
-- Lars' Profile → selfhosted tier with email verified
UPDATE profiles
SET
tier = 'selfhosted',
email_verified = true
WHERE
email = 'lars@stommer.com'
OR role = 'admin';
-- Other existing profiles → free tier, unverified
UPDATE profiles
SET
tier = 'free',
email_verified = false
WHERE
tier IS NULL
OR tier = '';
-- Initialize user_stats for existing profiles
INSERT INTO user_stats (profile_id, weight_entries_count, photos_count)
SELECT
p.id,
(SELECT COUNT(*) FROM weight_log WHERE profile_id = p.id),
(SELECT COUNT(*) FROM photos WHERE profile_id = p.id)
FROM profiles p
ON CONFLICT (profile_id) DO NOTHING;
-- ============================================================================
-- Create indexes for performance
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_tier_limits_tier ON tier_limits(tier_id);
CREATE INDEX IF NOT EXISTS idx_tier_limits_feature ON tier_limits(feature_id);
CREATE INDEX IF NOT EXISTS idx_user_restrictions_profile ON user_feature_restrictions(profile_id);
CREATE INDEX IF NOT EXISTS idx_user_usage_profile ON user_feature_usage(profile_id);
CREATE INDEX IF NOT EXISTS idx_access_grants_profile ON access_grants(profile_id, valid_until DESC);
CREATE INDEX IF NOT EXISTS idx_access_grants_active ON access_grants(profile_id, is_active, valid_until DESC);
CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code);
CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_profile ON coupon_redemptions(profile_id);
-- ============================================================================
-- Migration complete
-- ============================================================================
-- Run this migration with:
-- psql -h localhost -U mitai_prod -d mitai_prod < backend/migrations/v9c_subscription_system.sql
--
-- Or via Docker:
-- docker exec -i mitai-postgres psql -U mitai_prod -d mitai_prod < backend/migrations/v9c_subscription_system.sql
-- ============================================================================