All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m12s
- Enhanced the ACCESS_LAYER_AND_GOVERNANCE_PLAN.md with new specifications for capability documentation and community features. - Added references to new documents detailing capability IDs and club membership features. - Updated MULTI_TENANCY_RBAC_ARCHITECTURE.md to include links to the new specifications. - Marked certain features as deprecated in backend/auth.py, indicating migration paths for club feature access. - Incremented DB_SCHEMA_VERSION to 20260606078 in version.py to reflect recent changes.
265 lines
12 KiB
SQL
265 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
|
|
ALTER TABLE features RENAME TO features_legacy_001;
|
|
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
|
|
ALTER TABLE tier_limits RENAME TO tier_limits_legacy_001;
|
|
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
|
|
ALTER TABLE user_feature_usage RENAME TO user_feature_usage_legacy_001;
|
|
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
|
|
);
|