shinkan-jinkendo/backend/migrations/078_club_features_and_plans.sql
Lars 7db77f4738
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Failing after 43s
Test Suite / pytest-backend (push) Failing after 31s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Has been cancelled
Improve Deployment Workflow and Database Migration Logic
- Enhanced the deployment workflow to include error handling for the DEV API, ensuring logs are captured if the API is unreachable.
- Updated the migration scripts to safely rename existing tables by checking for their existence, preventing potential conflicts during migrations.
- Added exception handling in migration 079 to ensure the prerequisites are met before proceeding with the creation of the capabilities table.
2026-06-07 06:41:22 +02:00

287 lines
12 KiB
SQL

-- 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
);