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
+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()