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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user