Files
denshooter 5bc81d5b3b Initial commit: nightly iOS app + Supabase backend
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>
2026-04-23 23:31:38 +02:00

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