5bc81d5b3b
iOS SwiftUI app with Supabase auth/realtime, Node.js backend, Docker/Supabase self-hosted infrastructure, and APNs scheduler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
238 lines
10 KiB
PL/PgSQL
238 lines
10 KiB
PL/PgSQL
-- 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);
|