mitai-jinkendo/backend/migrations/v9c_subscription_system.sql
Lars a8df7f8359
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
fix: correct UUID foreign key constraints in v9c migration
Changed all profile_id columns from TEXT to UUID to match profiles.id type.
Changed all auto-generated IDs from gen_random_uuid() to uuid_generate_v4()
to match existing schema.sql convention.

Fixed tables:
- tier_limits: id TEXT → UUID
- user_feature_restrictions: id, profile_id, created_by TEXT → UUID
- user_feature_usage: id, profile_id TEXT → UUID
- coupons: id, created_by TEXT → UUID
- coupon_redemptions: id, coupon_id, profile_id, access_grant_id TEXT → UUID
- access_grants: id, profile_id, coupon_id, paused_by TEXT → UUID
- user_activity_log: id, profile_id TEXT → UUID
- user_stats: profile_id TEXT → UUID
- profiles.invited_by: TEXT → UUID

This fixes: foreign key constraint "user_feature_restrictions_profile_id_fkey"
cannot be implemented - Key columns "profile_id" and "id" are of
incompatible types: text and uuid

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:50:12 +01:00

353 lines
17 KiB
SQL

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