-- nightly — Vollständiges Datenbankschema mit Row Level Security -- Wird einmalig nach dem ersten Supabase-Start ausgeführt -- ── Enums ────────────────────────────────────────────────────────────────── DO $$ BEGIN CREATE TYPE post_mood AS ENUM ('still', 'unruhig', 'melancholisch', 'aufgedreht'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE TYPE review_status AS ENUM ('ok', 'urgent', 'hidden', 'approved', 'removed'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE TYPE report_reason AS ENUM ('hate', 'harassment', 'selfharm', 'illegal', 'spam', 'other'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; -- ── Tabellen ──────────────────────────────────────────────────────────────── -- Erweiterung des Supabase-Auth-Schemas CREATE TABLE IF NOT EXISTS public.profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, username TEXT UNIQUE NOT NULL CHECK (username ~ '^[a-z0-9_.]{3,20}$'), display_name TEXT NOT NULL, bio TEXT, avatar_url TEXT, push_token TEXT, is_pro BOOLEAN DEFAULT FALSE NOT NULL, is_admin BOOLEAN DEFAULT FALSE NOT NULL, anon_slots_used INT DEFAULT 0 NOT NULL, anon_slots_reset_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL ); CREATE TABLE IF NOT EXISTS public.posts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, content TEXT NOT NULL CHECK (LENGTH(TRIM(content)) BETWEEN 1 AND 280), mood post_mood, is_anonymous BOOLEAN DEFAULT FALSE NOT NULL, review_status review_status DEFAULT 'ok' NOT NULL, deleted_at TIMESTAMPTZ, -- soft delete: Post bleibt im Tagebuch created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL ); CREATE TABLE IF NOT EXISTS public.resonances ( post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, PRIMARY KEY (post_id, user_id) ); CREATE TABLE IF NOT EXISTS public.whispers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), from_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, to_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, post_id UUID REFERENCES public.posts(id) ON DELETE SET NULL, content TEXT NOT NULL CHECK (LENGTH(TRIM(content)) BETWEEN 1 AND 140), read_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, CHECK (from_user_id != to_user_id) ); CREATE TABLE IF NOT EXISTS public.follows ( follower_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, following_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, PRIMARY KEY (follower_id, following_id), CHECK (follower_id != following_id) ); CREATE TABLE IF NOT EXISTS public.reports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE, reporter_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, reason report_reason NOT NULL, details TEXT CHECK (LENGTH(details) <= 500), resolved_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, UNIQUE (post_id, reporter_id) ); -- ── Indizes ───────────────────────────────────────────────────────────────── CREATE INDEX IF NOT EXISTS idx_posts_user_id ON public.posts(user_id); CREATE INDEX IF NOT EXISTS idx_posts_created_at ON public.posts(created_at DESC); CREATE INDEX IF NOT EXISTS idx_posts_review ON public.posts(review_status) WHERE review_status != 'ok'; CREATE INDEX IF NOT EXISTS idx_resonances_post ON public.resonances(post_id); CREATE INDEX IF NOT EXISTS idx_follows_follower ON public.follows(follower_id); CREATE INDEX IF NOT EXISTS idx_whispers_to ON public.whispers(to_user_id, read_at); -- ── View: Feed Posts ──────────────────────────────────────────────────────── -- Wird in der iOS-App direkt abgefragt — joins alles zusammen CREATE OR REPLACE VIEW public.feed_posts AS SELECT p.id, p.content, p.mood, p.is_anonymous, p.created_at, -- Autor (für anonyme Posts: null) CASE WHEN p.is_anonymous THEN NULL ELSE p.user_id END AS author_id, CASE WHEN p.is_anonymous THEN NULL ELSE pr.username END AS author_username, CASE WHEN p.is_anonymous THEN NULL ELSE pr.display_name END AS author_display_name, CASE WHEN p.is_anonymous THEN NULL ELSE pr.avatar_url END AS author_avatar_url, -- Resonance-Zähler COALESCE(( SELECT COUNT(*) FROM public.resonances r WHERE r.post_id = p.id ), 0)::int AS resonance_count FROM public.posts p JOIN public.profiles pr ON pr.id = p.user_id WHERE p.deleted_at IS NULL AND p.review_status IN ('ok', 'approved') AND p.created_at > NOW() - INTERVAL '14 hours'; -- ── Trigger: Profil automatisch anlegen ───────────────────────────────────── CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN INSERT INTO public.profiles (id, username, display_name) VALUES ( NEW.id, COALESCE(NEW.raw_user_meta_data->>'username', 'user_' || SUBSTR(NEW.id::text, 1, 8)), COALESCE(NEW.raw_user_meta_data->>'display_name', 'User') ) ON CONFLICT (id) DO NOTHING; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); -- ── Trigger: Auto-Hidden bei >= 5 Reports ─────────────────────────────────── CREATE OR REPLACE FUNCTION public.handle_new_report() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE v_count int; BEGIN SELECT COUNT(*) INTO v_count FROM public.reports WHERE post_id = NEW.post_id; IF v_count >= 5 THEN UPDATE public.posts SET review_status = 'hidden' WHERE id = NEW.post_id AND review_status = 'ok'; END IF; IF NEW.reason IN ('selfharm', 'illegal') THEN UPDATE public.posts SET review_status = 'urgent' WHERE id = NEW.post_id AND review_status = 'ok'; END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS on_new_report ON public.reports; CREATE TRIGGER on_new_report AFTER INSERT ON public.reports FOR EACH ROW EXECUTE FUNCTION public.handle_new_report(); -- ── Funktion: Username-Login ───────────────────────────────────────────────── -- Gibt die E-Mail zu einem Username zurück (nur für Auth-Flow) CREATE OR REPLACE FUNCTION public.get_email_by_username(p_username text) RETURNS text LANGUAGE plpgsql SECURITY DEFINER AS $$ DECLARE v_email text; BEGIN SELECT u.email INTO v_email FROM auth.users u JOIN public.profiles p ON p.id = u.id WHERE LOWER(p.username) = LOWER(p_username); RETURN v_email; END; $$; -- Funktion: Account vollständig löschen (DSGVO) CREATE OR REPLACE FUNCTION public.delete_my_account() RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN DELETE FROM auth.users WHERE id = auth.uid(); END; $$; -- ── Row Level Security (RLS) ──────────────────────────────────────────────── ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY; ALTER TABLE public.resonances ENABLE ROW LEVEL SECURITY; ALTER TABLE public.whispers ENABLE ROW LEVEL SECURITY; ALTER TABLE public.follows ENABLE ROW LEVEL SECURITY; ALTER TABLE public.reports ENABLE ROW LEVEL SECURITY; -- profiles: jeder darf lesen, nur eigenes darf geändert werden CREATE POLICY "profiles_select" ON public.profiles FOR SELECT USING (true); CREATE POLICY "profiles_update" ON public.profiles FOR UPDATE USING (auth.uid() = id); -- posts: Feed-Sichtbarkeit (über View geregelt), eigene Verwaltung CREATE POLICY "posts_select" ON public.posts FOR SELECT USING ( deleted_at IS NULL AND review_status IN ('ok', 'approved') AND ( NOT is_anonymous OR user_id = auth.uid() ) ); CREATE POLICY "posts_insert" ON public.posts FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "posts_update_own" ON public.posts FOR UPDATE USING (auth.uid() = user_id AND deleted_at IS NULL); -- Soft-Delete: nur eigene Posts dürfen als gelöscht markiert werden CREATE POLICY "posts_softdelete" ON public.posts FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (deleted_at IS NOT NULL); -- resonances CREATE POLICY "resonances_select" ON public.resonances FOR SELECT USING (true); CREATE POLICY "resonances_insert" ON public.resonances FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "resonances_delete" ON public.resonances FOR DELETE USING (auth.uid() = user_id); -- whispers: nur Absender und Empfänger CREATE POLICY "whispers_select" ON public.whispers FOR SELECT USING (auth.uid() = from_user_id OR auth.uid() = to_user_id); CREATE POLICY "whispers_insert" ON public.whispers FOR INSERT WITH CHECK (auth.uid() = from_user_id); -- follows CREATE POLICY "follows_select" ON public.follows FOR SELECT USING (true); CREATE POLICY "follows_insert" ON public.follows FOR INSERT WITH CHECK (auth.uid() = follower_id); CREATE POLICY "follows_delete" ON public.follows FOR DELETE USING (auth.uid() = follower_id); -- reports: nur eigene einsehen CREATE POLICY "reports_insert" ON public.reports FOR INSERT WITH CHECK (auth.uid() = reporter_id); CREATE POLICY "reports_select_own" ON public.reports FOR SELECT USING (auth.uid() = reporter_id);