-- Migration 078: Vereins-Feature-Registry (Mitai-v9c-Pattern) + club_plans/subscriptions -- Spez: .claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md (M1) -- Legacy 001 (SERIAL features, profile tier_limits) wird archiviert, nicht gelöscht. -- ── 1. Legacy-Tabellen archivieren (nur alte Struktur) ───────────────────── DO $migration$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'features' ) AND EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'name' ) AND NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'features' AND column_name = 'limit_type' ) THEN -- Nach abgebrochenem Erstversuch kann features_legacy_001 schon existieren IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'features_legacy_001' ) THEN DROP TABLE features; ELSE ALTER TABLE features RENAME TO features_legacy_001; END IF; END IF; IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'tier_limits' ) AND EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'tier_limits' AND column_name = 'tier' ) THEN IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'tier_limits_legacy_001' ) THEN DROP TABLE tier_limits; ELSE ALTER TABLE tier_limits RENAME TO tier_limits_legacy_001; END IF; END IF; IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'user_feature_usage' ) AND EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'user_feature_usage' AND column_name = 'profile_id' ) THEN IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'user_feature_usage_legacy_001' ) THEN DROP TABLE user_feature_usage; ELSE ALTER TABLE user_feature_usage RENAME TO user_feature_usage_legacy_001; END IF; END IF; END $migration$; -- ── 2. Feature-Registry (TEXT-PK, app=shinkan) ──────────────────────────── CREATE TABLE IF NOT EXISTS features ( id TEXT PRIMARY KEY, app TEXT NOT NULL DEFAULT 'shinkan', name TEXT NOT NULL, description TEXT, category TEXT NOT NULL DEFAULT 'content', limit_type TEXT NOT NULL DEFAULT 'count' CHECK (limit_type IN ('count', 'boolean')), reset_period TEXT NOT NULL DEFAULT 'never' CHECK (reset_period IN ('never', 'daily', 'monthly')), default_limit INTEGER, enforcement_subject TEXT NOT NULL DEFAULT 'club' CHECK (enforcement_subject IN ('club', 'profile', 'portal')), active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_features_app ON features(app) WHERE active = true; -- ── 3. Vereins-Produkte ───────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS club_plans ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, price_monthly_cents INTEGER, price_yearly_cents INTEGER, stripe_price_id_monthly TEXT, stripe_price_id_yearly TEXT, active BOOLEAN NOT NULL DEFAULT true, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS club_plan_limits ( id SERIAL PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES club_plans(id) ON DELETE CASCADE, feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, limit_value INTEGER, UNIQUE (plan_id, feature_id) ); CREATE INDEX IF NOT EXISTS idx_club_plan_limits_plan ON club_plan_limits(plan_id); CREATE TABLE IF NOT EXISTS club_subscriptions ( id SERIAL PRIMARY KEY, club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, plan_id TEXT NOT NULL REFERENCES club_plans(id), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'trial', 'past_due', 'cancelled')), started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ends_at TIMESTAMPTZ, trial_ends_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (club_id) ); CREATE INDEX IF NOT EXISTS idx_club_subscriptions_plan ON club_subscriptions(plan_id); CREATE TABLE IF NOT EXISTS club_feature_overrides ( id SERIAL PRIMARY KEY, club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, limit_value INTEGER NOT NULL, reason TEXT, set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (club_id, feature_id) ); CREATE TABLE IF NOT EXISTS club_access_grants ( id SERIAL PRIMARY KEY, club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, plan_id TEXT REFERENCES club_plans(id) ON DELETE SET NULL, feature_id TEXT REFERENCES features(id) ON DELETE SET NULL, grant_limit INTEGER, starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL, reason TEXT, created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_club_access_grants_club ON club_access_grants(club_id); CREATE INDEX IF NOT EXISTS idx_club_access_grants_window ON club_access_grants(club_id, starts_at, ends_at); CREATE TABLE IF NOT EXISTS club_feature_usage ( id SERIAL PRIMARY KEY, club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, usage_count INTEGER NOT NULL DEFAULT 0, reset_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (club_id, feature_id) ); CREATE INDEX IF NOT EXISTS idx_club_feature_usage_club ON club_feature_usage(club_id); CREATE TABLE IF NOT EXISTS club_feature_usage_events ( id BIGSERIAL PRIMARY KEY, club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE, feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, action TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_club_feature_usage_events_club ON club_feature_usage_events(club_id, created_at DESC); -- ── 4. Seed: Features ───────────────────────────────────────────────────── INSERT INTO features (id, app, name, description, category, limit_type, reset_period, default_limit, enforcement_subject) VALUES ('exercises', 'shinkan', 'Übungen', 'Anzahl Übungen im Verein (Bestand)', 'content', 'count', 'never', 100, 'club'), ('exercise_media', 'shinkan', 'Medien-Uploads', 'Medien-Uploads pro Monat', 'content', 'count', 'monthly', 20, 'club'), ('training_units', 'shinkan', 'Trainingseinheiten', 'Trainingseinheiten pro Monat', 'planning', 'count', 'monthly', 40, 'club'), ('training_programs', 'shinkan', 'Trainingsprogramme', 'Module und Rahmenprogramme (Bestand)', 'planning', 'count', 'never', 5, 'club'), ('training_groups', 'shinkan', 'Trainingsgruppen', 'Anzahl Trainingsgruppen', 'org', 'count', 'never', 10, 'club'), ('active_members', 'shinkan', 'Aktive Mitglieder', 'Anzahl aktiver Vereinsmitglieder', 'org', 'count', 'never', 25, 'club'), ('ai_calls', 'shinkan', 'KI-Aufrufe', 'KI-Aufrufe pro Monat (Suggest, Regenerate, Planung)', 'ai', 'count', 'monthly', 0, 'club'), ('ai_pipeline', 'shinkan', 'KI-Pipeline', 'Erweiterte KI-Batch-Pipelines', 'ai', 'boolean', 'never', 0, 'club'), ('wiki_import', 'shinkan', 'Wiki-Import', 'MediaWiki-Import (Plattform)', 'integration', 'boolean', 'never', 0, 'portal'), ('data_export', 'shinkan', 'Daten-Export', 'Export-Funktionen', 'integration', 'boolean', 'never', 0, 'club') ON CONFLICT (id) DO NOTHING; -- ── 5. Seed: Pläne ────────────────────────────────────────────────────────── INSERT INTO club_plans (id, name, description, sort_order, active) VALUES ('free', 'Free', 'Einstieg für Vereine', 0, true), ('verein_starter', 'Verein Starter', 'Erweiterte Kontingente', 10, true), ('verein_pro', 'Verein Pro', 'Hohe Limits und KI-Kontingent', 20, true), ('pilot', 'Pilot', 'Pilotverein mit großzügigen Limits', 5, true) ON CONFLICT (id) DO NOTHING; -- Plan-Limits: free INSERT INTO club_plan_limits (plan_id, feature_id, limit_value) SELECT 'free', f.id, CASE f.id WHEN 'exercises' THEN 100 WHEN 'exercise_media' THEN 20 WHEN 'training_units' THEN 40 WHEN 'training_programs' THEN 5 WHEN 'training_groups' THEN 10 WHEN 'active_members' THEN 25 WHEN 'ai_calls' THEN 0 WHEN 'ai_pipeline' THEN 0 WHEN 'wiki_import' THEN 0 WHEN 'data_export' THEN 0 END FROM features f WHERE f.app = 'shinkan' ON CONFLICT (plan_id, feature_id) DO NOTHING; -- Plan-Limits: verein_starter INSERT INTO club_plan_limits (plan_id, feature_id, limit_value) SELECT 'verein_starter', f.id, CASE f.id WHEN 'exercises' THEN 500 WHEN 'exercise_media' THEN 80 WHEN 'training_units' THEN 200 WHEN 'training_programs' THEN 30 WHEN 'training_groups' THEN 30 WHEN 'active_members' THEN 80 WHEN 'ai_calls' THEN 30 WHEN 'ai_pipeline' THEN 0 WHEN 'wiki_import' THEN 0 WHEN 'data_export' THEN 1 END FROM features f WHERE f.app = 'shinkan' ON CONFLICT (plan_id, feature_id) DO NOTHING; -- Plan-Limits: verein_pro (NULL = unbegrenzt wo sinnvoll) INSERT INTO club_plan_limits (plan_id, feature_id, limit_value) SELECT 'verein_pro', f.id, CASE f.id WHEN 'exercises' THEN NULL WHEN 'exercise_media' THEN 300 WHEN 'training_units' THEN NULL WHEN 'training_programs' THEN NULL WHEN 'training_groups' THEN NULL WHEN 'active_members' THEN NULL WHEN 'ai_calls' THEN 200 WHEN 'ai_pipeline' THEN 1 WHEN 'wiki_import' THEN 0 WHEN 'data_export' THEN 1 END FROM features f WHERE f.app = 'shinkan' ON CONFLICT (plan_id, feature_id) DO NOTHING; -- Plan-Limits: pilot INSERT INTO club_plan_limits (plan_id, feature_id, limit_value) SELECT 'pilot', f.id, CASE f.id WHEN 'exercises' THEN NULL WHEN 'exercise_media' THEN NULL WHEN 'training_units' THEN NULL WHEN 'training_programs' THEN NULL WHEN 'training_groups' THEN NULL WHEN 'active_members' THEN NULL WHEN 'ai_calls' THEN 100 WHEN 'ai_pipeline' THEN 1 WHEN 'wiki_import' THEN 0 WHEN 'data_export' THEN 1 END FROM features f WHERE f.app = 'shinkan' ON CONFLICT (plan_id, feature_id) DO NOTHING; -- ── 6. Backfill: bestehende Vereine → Plan free ─────────────────────────── INSERT INTO club_subscriptions (club_id, plan_id, status) SELECT c.id, 'free', 'active' FROM clubs c WHERE NOT EXISTS ( SELECT 1 FROM club_subscriptions cs WHERE cs.club_id = c.id );