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>
This commit is contained in:
denshooter
2026-04-23 23:31:38 +02:00
commit 5bc81d5b3b
80 changed files with 9958 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# nightly-spezifische Umgebungsvariablen
# (Supabase-eigene Variablen stehen in .env)
# APNs — Apple Push Notifications
# ⚠️ Erfordert bezahlten Apple Developer Account ($99/Jahr)
# Ohne Account: diese Zeilen auskommentiert lassen
# APNS_KEY_PATH=/app/certs/AuthKey_XXXXXXXXXX.p8
# APNS_KEY_ID=XXXXXXXXXX
# APNS_TEAM_ID=XXXXXXXXXX
APNS_BUNDLE_ID=app.xxx # Ersetze mit deiner Bundle-ID, z.B. app.nightly
# App-Konfiguration
WINDOW_START_HOUR=2 # Fenster öffnet 02:00 Uhr
WINDOW_END_HOUR=5 # Fenster schließt 05:00 Uhr
ANON_SLOTS_PER_NIGHT=3 # Anonyme Posts pro User pro Nacht
+19
View File
@@ -0,0 +1,19 @@
# Ersetze xxx mit deinem App-Namen
# z.B. api.nightly.dk0.dev
# Supabase API (PostgREST, Auth, Storage, Realtime)
api.xxx.dk0.dev {
reverse_proxy kong:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
# Supabase Studio (Admin-Dashboard)
# ⚠️ Zugriff absichern! Nur von bekannten IPs erlauben (optional).
studio.xxx.dk0.dev {
reverse_proxy studio:3000
# IP-Whitelist (optional, empfohlen):
# @blocked not remote_ip 1.2.3.4
# respond @blocked 403
}
@@ -0,0 +1,43 @@
# Ergänzungen zum offiziellen Supabase docker-compose.yml
# Start: docker compose -p nightly -f docker-compose.yml -f docker-compose.override.yml up -d
services:
# Schema-Migration wird beim ersten Postgres-Start eingespielt
db:
volumes:
- ./volumes/db/data:/var/lib/postgresql/data:Z
- ../nightly-migrations/001_schema.sql:/docker-entrypoint-initdb.d/99_nightly.sql:ro
# Kong (Supabase API-Gateway) → im proxy-Netzwerk für NPM
kong:
networks:
- default
- proxy
# Studio (Admin-Dashboard) → im proxy-Netzwerk für NPM
studio:
networks:
- default
- proxy
# Nightly Scheduler: APNs-Ping + Resonance-Milestones
# ⚠️ APNs: erfordert bezahlten Apple Developer Account ($99/Jahr)
scheduler:
build: ./scheduler
restart: unless-stopped
env_file:
- .env.nightly
environment:
SUPABASE_URL: http://kong:8000
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
depends_on:
- kong
networks:
- default
networks:
# Externes Netzwerk das NPM und Supabase teilen
# Anlegen mit: docker network create proxy
proxy:
external: true
+237
View File
@@ -0,0 +1,237 @@
-- 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);
+8
View File
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
# APNs-Zertifikat-Verzeichnis (optional)
RUN mkdir -p /app/certs
CMD ["node", "src/index.js"]
+13
View File
@@ -0,0 +1,13 @@
{
"name": "nightly-scheduler",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@supabase/supabase-js": "^2.49.0",
"node-apn": "^3.0.0"
}
}
+216
View File
@@ -0,0 +1,216 @@
/**
* nightly — APNs Scheduler
*
* Sendet täglich einen Push-Ping an alle User zu einem zufälligen
* Zeitpunkt zwischen WINDOW_START_HOUR und WINDOW_END_HOUR.
*
* ⚠️ APNs erfordert einen bezahlten Apple Developer Account ($99/Jahr).
* Ohne APNS_KEY_PATH läuft der Scheduler, sendet aber keine Pushs.
*/
import { createClient } from '@supabase/supabase-js'
import apn from 'node-apn'
// ── Supabase Client ────────────────────────────────────────────────────────
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY // Service Role für Admin-Zugriff
)
// ── APNs Provider ───────────────────────────────────────────────────────────
const APNS_ENABLED = !!process.env.APNS_KEY_PATH
let apnProvider = null
if (APNS_ENABLED) {
apnProvider = new apn.Provider({
token: {
key: process.env.APNS_KEY_PATH,
keyId: process.env.APNS_KEY_ID,
teamId: process.env.APNS_TEAM_ID,
},
production: process.env.NODE_ENV === 'production'
})
console.log('[apns] Provider initialisiert')
} else {
console.warn('[apns] Kein APNS_KEY_PATH gesetzt — Push-Benachrichtigungen deaktiviert')
console.warn('[apns] Für APNs wird ein bezahlter Apple Developer Account benötigt ($99/Jahr)')
}
// ── Konfiguration ───────────────────────────────────────────────────────────
const WINDOW_START = parseInt(process.env.WINDOW_START_HOUR ?? '2')
const WINDOW_END = parseInt(process.env.WINDOW_END_HOUR ?? '5')
const BUNDLE_ID = process.env.APNS_BUNDLE_ID ?? 'app.nightly'
// Nachrichten für den nächtlichen Ping
const PING_MESSAGES = [
{ title: 'nightly', body: 'Das Fenster ist offen. Was geht dir durch den Kopf? 🌙' },
{ title: 'nightly', body: 'Alle anderen sind auch wach. Was beschäftigt dich gerade?' },
{ title: 'nightly', body: 'Kein Filter. Keine Maske. Nur dein Gedanke.' },
{ title: 'nightly', body: 'Was würdest du sagen, wenn niemand zuhört?' },
{ title: 'nightly', body: 'Es ist mitten in der Nacht. Sei ehrlich.' },
]
// ── Ping-Zeit-Verwaltung ────────────────────────────────────────────────────
// Jeden Tag wird eine neue zufällige Zeit festgelegt
let todaysPingTime = null
let lastPingDate = null
function getTodaysPingTime() {
const today = new Date().toDateString()
if (lastPingDate === today && todaysPingTime) {
return todaysPingTime
}
const hour = WINDOW_START + Math.floor(Math.random() * (WINDOW_END - WINDOW_START))
const minute = Math.floor(Math.random() * 60)
todaysPingTime = { hour, minute }
lastPingDate = today
console.log(`[scheduler] Heutiger Ping: ${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')} Uhr`)
return todaysPingTime
}
// ── Ping senden ─────────────────────────────────────────────────────────────
async function sendNightlyPing() {
console.log('[scheduler] Sende nightly Ping...')
const { data: profiles, error } = await supabase
.from('profiles')
.select('id, push_token')
.not('push_token', 'is', null)
if (error) {
console.error('[scheduler] Fehler beim Abrufen der Tokens:', error)
return
}
if (!profiles?.length) {
console.log('[scheduler] Keine registrierten Push-Tokens')
return
}
if (!APNS_ENABLED) {
console.log(`[scheduler] APNs deaktiviert — würde an ${profiles.length} User senden`)
return
}
const msg = PING_MESSAGES[Math.floor(Math.random() * PING_MESSAGES.length)]
let sent = 0
let failed = 0
for (const { push_token } of profiles) {
const note = new apn.Notification()
note.expiry = Math.floor(Date.now() / 1000) + 3 * 3600 // 3h gültig
note.badge = 1
note.sound = 'default'
note.alert = msg
note.topic = BUNDLE_ID
note.payload = { type: 'nightly_ping' }
try {
const result = await apnProvider.send(note, push_token)
if (result.failed?.length > 0) {
failed++
// Token ungültig → aus DB entfernen
if (result.failed[0]?.response?.reason === 'BadDeviceToken') {
await supabase
.from('profiles')
.update({ push_token: null })
.eq('push_token', push_token)
}
} else {
sent++
}
} catch (err) {
failed++
console.error(`[apns] Fehler für Token:`, err.message)
}
}
console.log(`[scheduler] Ping gesendet: ${sent} erfolgreich, ${failed} fehlgeschlagen`)
}
// ── Resonance-Milestone-Benachrichtigungen ──────────────────────────────────
// "X Personen wurden von deinem Gedanken getroffen"
const MILESTONES = new Set([1, 5, 10, 25, 50, 100])
async function checkResonanceMilestones() {
// Holt Posts die in den letzten 2 Minuten eine neue Resonance erreicht haben
const { data: posts } = await supabase
.from('resonances')
.select('post_id, post:posts(user_id, content, is_anonymous)')
.gte('created_at', new Date(Date.now() - 2 * 60 * 1000).toISOString())
if (!posts?.length) return
// Gruppiere nach post_id
const grouped = {}
for (const r of posts) {
if (!grouped[r.post_id]) grouped[r.post_id] = r.post
}
for (const [postId, post] of Object.entries(grouped)) {
if (!post?.user_id) continue
const { count } = await supabase
.from('resonances')
.select('*', { count: 'exact', head: true })
.eq('post_id', postId)
if (!MILESTONES.has(count)) continue
const { data: profile } = await supabase
.from('profiles')
.select('push_token')
.eq('id', post.user_id)
.single()
if (!profile?.push_token || !APNS_ENABLED) continue
const note = new apn.Notification()
note.badge = 1
note.sound = 'default'
note.alert = {
title: 'nightly',
body: count === 1
? 'Dein Gedanke hat jemanden getroffen 🫀'
: `${count} Menschen wurden von deinem Gedanken getroffen 🫀`
}
note.topic = BUNDLE_ID
note.payload = { type: 'resonance_milestone', postId }
await apnProvider.send(note, profile.push_token).catch(() => {})
}
}
// ── Hauptschleife ────────────────────────────────────────────────────────────
console.log('[nightly scheduler] Gestartet')
console.log(`[nightly scheduler] Fenster: ${WINDOW_START}:00 ${WINDOW_END}:00 Uhr`)
setInterval(async () => {
const now = new Date()
const { hour, minute } = getTodaysPingTime()
// Ping zur festgelegten Zeit senden
if (now.getHours() === hour && now.getMinutes() === minute) {
await sendNightlyPing()
}
// Resonance-Milestones alle 2 Minuten prüfen
if (now.getMinutes() % 2 === 0) {
await checkResonanceMilestones()
}
}, 60_000)
// Sofortige Prüfung beim Start (ohne zu pingen)
getTodaysPingTime()
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# nightly — Server-Setup-Skript
# Führe das auf deinem Server aus: bash setup.sh
set -euo pipefail
DOMAIN="${1:-xxx.dk0.dev}" # Ersetze xxx mit deinem App-Namen
APP_DIR="/opt/nightly"
echo "▶ nightly setup für $DOMAIN"
# --- Abhängigkeiten ---
if ! command -v docker &>/dev/null; then
echo "▶ Docker installieren..."
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
fi
# --- Verzeichnis anlegen ---
sudo mkdir -p "$APP_DIR"
sudo chown "$USER:$USER" "$APP_DIR"
cd "$APP_DIR"
# --- Offizielles Supabase-Docker-Setup holen ---
if [ ! -d supabase ]; then
echo "▶ Supabase Docker-Konfiguration laden..."
git clone --depth 1 https://github.com/supabase/supabase.git supabase-repo
cp -r supabase-repo/docker supabase
rm -rf supabase-repo
fi
# --- Unsere Konfiguration kopieren ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cp "$SCRIPT_DIR/docker-compose.override.yml" "$APP_DIR/supabase/"
cp "$SCRIPT_DIR/Caddyfile" "$APP_DIR/supabase/"
cp -r "$SCRIPT_DIR/scheduler" "$APP_DIR/supabase/"
# --- .env anlegen wenn nicht vorhanden ---
if [ ! -f "$APP_DIR/supabase/.env" ]; then
cp "$APP_DIR/supabase/.env.example" "$APP_DIR/supabase/.env"
cp "$SCRIPT_DIR/.env.nightly" "$APP_DIR/supabase/.env.nightly"
echo ""
echo "⚠️ WICHTIG: Editiere $APP_DIR/supabase/.env und .env.nightly"
echo " Setze mindestens:"
echo " - POSTGRES_PASSWORD"
echo " - JWT_SECRET (min. 32 Zeichen)"
echo " - SITE_URL=https://api.$DOMAIN"
echo ""
echo " Dann: cd $APP_DIR/supabase && docker compose -f docker-compose.yml -f docker-compose.override.yml up -d"
exit 0
fi
# --- Migrations einspielen ---
echo "▶ Datenbank-Schema einspielen..."
# Warte auf Postgres
until docker compose exec -T db pg_isready -U postgres; do sleep 2; done
docker compose exec -T db psql -U postgres -d postgres \
< "$SCRIPT_DIR/migrations/001_schema.sql"
echo ""
echo "✅ Setup abgeschlossen!"
echo " Admin: https://studio.$DOMAIN"
echo " API: https://api.$DOMAIN"
echo " Logs: docker compose logs -f"