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,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()
|
||||
Reference in New Issue
Block a user