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
- 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.
287 lines
12 KiB
SQL
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
|
|
);
|