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 @@
DB_PASSWORD=change_me_use_a_strong_password
JWT_SECRET=change_me_use_a_very_long_random_string_min_64_chars
# Apple Push Notifications
APNS_KEY_PATH=/app/certs/AuthKey_XXXXXXXX.p8
APNS_KEY_ID=XXXXXXXXXX
APNS_TEAM_ID=XXXXXXXXXX
APNS_BUNDLE_ID=app.nightly
+6
View File
@@ -0,0 +1,6 @@
# Replace with your actual domain
api.nightly.app {
reverse_proxy api:3000 {
header_up X-Real-IP {remote_host}
}
}
+7
View File
@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]
+19
View File
@@ -0,0 +1,19 @@
{
"name": "nightly-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@node-rs/bcrypt": "^1.10.4",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"node-apn": "^3.0.0",
"pg": "^8.13.3",
"redis": "^4.7.0",
"sharp": "^0.33.5",
"uuid": "^11.1.0"
}
}
+89
View File
@@ -0,0 +1,89 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Users ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
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,
password_hash TEXT NOT NULL,
is_pro BOOLEAN DEFAULT FALSE,
is_admin BOOLEAN DEFAULT FALSE,
anon_slots_used INT DEFAULT 0,
anon_slots_reset_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ── Posts ──────────────────────────────────────────────────────────────────
CREATE TYPE post_mood AS ENUM ('still', 'unruhig', 'melancholisch', 'aufgedreht');
CREATE TYPE review_status AS ENUM ('ok', 'pending', 'urgent', 'hidden', 'approved', 'removed');
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES 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,
review_status review_status DEFAULT 'ok',
hidden_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ, -- soft delete: bleibt im persönlichen Tagebuch
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_posts_review ON posts(review_status) WHERE review_status != 'ok';
-- ── Resonances ("Hat mich getroffen") ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS resonances (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (post_id, user_id)
);
-- ── Whispers (private, einmalig, keine Antwort) ───────────────────────────
CREATE TABLE IF NOT EXISTS whispers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
from_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
to_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
content TEXT NOT NULL CHECK (LENGTH(TRIM(content)) BETWEEN 1 AND 280),
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ── Follows ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS follows (
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (follower_id, following_id)
);
-- ── Reports (Moderation) ───────────────────────────────────────────────────
CREATE TYPE report_reason AS ENUM (
'hate', 'harassment', 'selfharm', 'illegal', 'spam', 'other'
);
CREATE TABLE IF NOT EXISTS reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reason report_reason NOT NULL,
details TEXT,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (post_id, reporter_id) -- ein User kann denselben Post nur einmal melden
);
CREATE INDEX IF NOT EXISTS idx_reports_post_id ON reports(post_id);
CREATE INDEX IF NOT EXISTS idx_reports_unresolved ON reports(created_at) WHERE resolved_at IS NULL;
+31
View File
@@ -0,0 +1,31 @@
import express from 'express'
import { createClient } from 'redis'
import pg from 'pg'
import authRouter from './routes/auth.js'
import postsRouter from './routes/posts.js'
import usersRouter from './routes/users.js'
import moderationRouter from './routes/moderation.js'
import { schedulePing } from './scheduler.js'
const app = express()
app.use(express.json({ limit: '12mb' }))
// Postgres
export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL })
// Redis
export const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
redis.on('error', err => console.error('Redis error:', err))
// Routes
app.use('/auth', authRouter)
app.use('/posts', postsRouter)
app.use('/users', usersRouter)
app.use('/reports', moderationRouter)
app.get('/health', (_, res) => res.json({ ok: true }))
// Start nightly ping scheduler
schedulePing()
app.listen(3000, () => console.log('nightly API running on :3000'))
+14
View File
@@ -0,0 +1,14 @@
import jwt from 'jsonwebtoken'
export function authenticate(req, res, next) {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Nicht autorisiert' })
}
try {
req.user = jwt.verify(header.slice(7), process.env.JWT_SECRET)
next()
} catch {
res.status(401).json({ message: 'Token ungültig oder abgelaufen' })
}
}
+32
View File
@@ -0,0 +1,32 @@
import apn from 'node-apn'
let provider = null
function getProvider() {
if (provider) return provider
if (!process.env.APNS_KEY_PATH) return null
provider = 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'
})
return provider
}
export async function sendPushToToken(token, { title, body }) {
const p = getProvider()
if (!p) return
const note = new apn.Notification()
note.expiry = Math.floor(Date.now() / 1000) + 3600
note.badge = 1
note.sound = 'default'
note.alert = { title, body }
note.topic = process.env.APNS_BUNDLE_ID
await p.send(note, token)
}
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express'
import { hash, verify } from '@node-rs/bcrypt'
import jwt from 'jsonwebtoken'
import { v4 as uuid } from 'uuid'
import { db } from '../index.js'
const router = Router()
router.post('/register', async (req, res) => {
const { username, password, displayName } = req.body ?? {}
if (!username || !password || !displayName) {
return res.status(400).json({ message: 'Alle Felder erforderlich' })
}
if (!/^[a-z0-9_.]{3,20}$/.test(username)) {
return res.status(400).json({ message: 'Benutzername: 320 Zeichen, nur a-z 0-9 _ .' })
}
if (password.length < 8) {
return res.status(400).json({ message: 'Passwort muss mindestens 8 Zeichen haben' })
}
try {
const { rows } = await db.query('SELECT id FROM users WHERE username = $1', [username])
if (rows.length > 0) return res.status(409).json({ message: 'Benutzername bereits vergeben' })
const passwordHash = await hash(password, 12)
const id = uuid()
await db.query(
'INSERT INTO users (id, username, display_name, password_hash) VALUES ($1,$2,$3,$4)',
[id, username, displayName, passwordHash]
)
const token = jwt.sign({ userId: id }, process.env.JWT_SECRET, { expiresIn: '90d' })
res.status(201).json({ token })
} catch (err) {
console.error('register:', err)
res.status(500).json({ message: 'Serverfehler' })
}
})
router.post('/login', async (req, res) => {
const { username, password } = req.body ?? {}
try {
const { rows } = await db.query('SELECT * FROM users WHERE username = $1', [username])
const user = rows[0]
if (!user || !await verify(password, user.password_hash)) {
return res.status(401).json({ message: 'Benutzername oder Passwort falsch' })
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '90d' })
res.json({ token })
} catch (err) {
console.error('login:', err)
res.status(500).json({ message: 'Serverfehler' })
}
})
export default router
+110
View File
@@ -0,0 +1,110 @@
import { Router } from 'express'
import { v4 as uuid } from 'uuid'
import { db } from '../index.js'
import { authenticate } from '../middleware/auth.js'
const router = Router()
router.use(authenticate)
const VALID_REASONS = new Set([
'hate', 'harassment', 'selfharm', 'illegal', 'spam', 'other'
])
// POST /reports — User meldet einen Post
router.post('/', async (req, res) => {
const { postId, reason, details } = req.body ?? {}
if (!postId || !VALID_REASONS.has(reason)) {
return res.status(400).json({ message: 'Pflichtfelder fehlen' })
}
// Duplicate check: ein User kann denselben Post nur einmal melden
const { rows: existing } = await db.query(
'SELECT id FROM reports WHERE post_id = $1 AND reporter_id = $2',
[postId, req.user.userId]
)
if (existing.length > 0) {
return res.status(409).json({ message: 'Bereits gemeldet' })
}
await db.query(`
INSERT INTO reports (id, post_id, reporter_id, reason, details)
VALUES ($1, $2, $3, $4, $5)
`, [uuid(), postId, req.user.userId, reason, details?.slice(0, 500) ?? null])
// Bei kritischen Inhalten (Selbstverletzung) sofort flaggen
if (reason === 'selfharm' || reason === 'illegal') {
await db.query(
"UPDATE posts SET review_status = 'urgent' WHERE id = $1",
[postId]
)
}
// Automatisch ausblenden wenn >= 5 Reports (Schwelle anpassbar)
const { rows: [{ count }] } = await db.query(
'SELECT COUNT(*) FROM reports WHERE post_id = $1', [postId]
)
if (parseInt(count) >= 5) {
await db.query(
"UPDATE posts SET review_status = 'hidden', hidden_at = NOW() WHERE id = $1 AND review_status != 'approved'",
[postId]
)
}
res.status(201).json({ ok: true })
})
// GET /reports/queue — Admin: offene Reports (nur für Admins)
router.get('/queue', requireAdmin, async (req, res) => {
const { rows } = await db.query(`
SELECT
p.id AS post_id, p.content, p.review_status, p.created_at AS post_created_at,
u.username AS author_username, p.is_anonymous,
COUNT(r.id)::int AS report_count,
ARRAY_AGG(DISTINCT r.reason) AS reasons,
MIN(r.created_at) AS first_reported_at
FROM posts p
JOIN users u ON u.id = p.user_id
JOIN reports r ON r.post_id = p.id
WHERE p.review_status IN ('pending', 'urgent', 'hidden')
GROUP BY p.id, u.username
ORDER BY
CASE p.review_status WHEN 'urgent' THEN 0 WHEN 'hidden' THEN 1 ELSE 2 END,
report_count DESC
LIMIT 100
`)
res.json(rows)
})
// PATCH /reports/posts/:id — Admin: Post genehmigen oder löschen
router.patch('/posts/:id', requireAdmin, async (req, res) => {
const { action } = req.body // 'approve' | 'remove' | 'warn'
if (!['approve', 'remove', 'warn'].includes(action)) {
return res.status(400).json({ message: 'Ungültige Aktion' })
}
if (action === 'approve') {
await db.query(
"UPDATE posts SET review_status = 'approved', hidden_at = NULL WHERE id = $1",
[req.params.id]
)
} else if (action === 'remove') {
await db.query(
"UPDATE posts SET review_status = 'removed', deleted_at = NOW() WHERE id = $1",
[req.params.id]
)
// Optional: User verwarnen
}
res.json({ ok: true })
})
function requireAdmin(req, res, next) {
if (!req.user?.isAdmin) {
return res.status(403).json({ message: 'Nicht erlaubt' })
}
next()
}
export default router
+191
View File
@@ -0,0 +1,191 @@
import { Router } from 'express'
import { v4 as uuid } from 'uuid'
import { db } from '../index.js'
import { authenticate } from '../middleware/auth.js'
const router = Router()
router.use(authenticate)
const VALID_MOODS = new Set(['still', 'unruhig', 'melancholisch', 'aufgedreht'])
const ANON_SLOTS_PER_NIGHT = 3
// GET /posts/feed
router.get('/feed', async (req, res) => {
try {
const { rows } = await db.query(`
SELECT
p.id, p.content, p.mood, p.is_anonymous, p.created_at,
-- Hide author info for anonymous posts (unless it's your own)
CASE WHEN p.is_anonymous AND p.user_id != $1 THEN NULL ELSE p.user_id END AS author_id,
CASE WHEN p.is_anonymous AND p.user_id != $1 THEN NULL ELSE u.username END AS username,
CASE WHEN p.is_anonymous AND p.user_id != $1 THEN NULL ELSE u.display_name END AS display_name,
CASE WHEN p.is_anonymous AND p.user_id != $1 THEN NULL ELSE u.avatar_url END AS avatar_url,
COUNT(r.user_id)::int AS resonance_count,
EXISTS(SELECT 1 FROM resonances WHERE post_id = p.id AND user_id = $1) AS has_resonated,
EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = u.id) AS is_following
FROM posts p
JOIN users u ON u.id = p.user_id
LEFT JOIN resonances r ON r.post_id = p.id
WHERE p.created_at > NOW() - INTERVAL '14 hours'
AND p.deleted_at IS NULL
AND (
p.user_id = $1
OR p.user_id IN (SELECT following_id FROM follows WHERE follower_id = $1)
)
GROUP BY p.id, u.id
ORDER BY p.created_at DESC
LIMIT 100
`, [req.user.userId])
res.json(rows.map(formatPost))
} catch (err) {
console.error('feed:', err)
res.status(500).json({ message: 'Serverfehler' })
}
})
// POST /posts
router.post('/', async (req, res) => {
const { content, mood, isAnonymous } = req.body ?? {}
if (!content?.trim()) return res.status(400).json({ message: 'Inhalt fehlt' })
if (content.length > 280) return res.status(400).json({ message: 'Maximal 280 Zeichen' })
if (!VALID_MOODS.has(mood)) return res.status(400).json({ message: 'Ungültige Stimmung' })
// One post per night window
const { rows: existing } = await db.query(`
SELECT id FROM posts
WHERE user_id = $1
AND created_at > NOW() - INTERVAL '14 hours'
AND deleted_at IS NULL
`, [req.user.userId])
if (existing.length > 0) {
return res.status(409).json({ message: 'Du hast heute Nacht schon gepostet' })
}
// Check anonymous slots
if (isAnonymous) {
const { rows: [user] } = await db.query(
'SELECT anon_slots_used, anon_slots_reset_at FROM users WHERE id = $1',
[req.user.userId]
)
const resetDate = user?.anon_slots_reset_at
const slotsUsed = resetDate && new Date(resetDate) > new Date(Date.now() - 24 * 3600 * 1000)
? (user.anon_slots_used ?? 0) : 0
if (slotsUsed >= ANON_SLOTS_PER_NIGHT) {
return res.status(403).json({ message: `Maximale Anonym-Posts (${ANON_SLOTS_PER_NIGHT}) für heute erreicht` })
}
await db.query(
'UPDATE users SET anon_slots_used = $1, anon_slots_reset_at = NOW() WHERE id = $2',
[slotsUsed + 1, req.user.userId]
)
}
try {
const id = uuid()
await db.query(
'INSERT INTO posts (id, user_id, content, mood, is_anonymous) VALUES ($1,$2,$3,$4,$5)',
[id, req.user.userId, content.trim(), mood, !!isAnonymous]
)
res.status(201).json({ id })
} catch (err) {
console.error('createPost:', err)
res.status(500).json({ message: 'Serverfehler' })
}
})
// POST /posts/:id/resonate — toggle resonance
router.post('/:id/resonate', async (req, res) => {
const { id } = req.params
const userId = req.user.userId
const { rows: [existing] } = await db.query(
'SELECT 1 FROM resonances WHERE post_id = $1 AND user_id = $2',
[id, userId]
)
if (existing) {
await db.query('DELETE FROM resonances WHERE post_id = $1 AND user_id = $2', [id, userId])
} else {
await db.query(
'INSERT INTO resonances (post_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[id, userId]
)
// Notify post author if it's not their own post
await notifyResonance(id, userId)
}
const { rows: [{ count }] } = await db.query(
'SELECT COUNT(*) FROM resonances WHERE post_id = $1', [id]
)
res.json({ resonanceCount: parseInt(count), hasResonated: !existing })
})
// DELETE /posts/:id/resonate — explicit unresoante
router.delete('/:id/resonate', async (req, res) => {
await db.query(
'DELETE FROM resonances WHERE post_id = $1 AND user_id = $2',
[req.params.id, req.user.userId]
)
res.json({ ok: true })
})
// Helper: send push when someone resonates with your post
async function notifyResonance(postId, fromUserId) {
try {
const { rows: [post] } = await db.query(
'SELECT user_id, is_anonymous FROM posts WHERE id = $1', [postId]
)
if (!post || post.user_id === fromUserId) return
const { rows: [author] } = await db.query(
'SELECT push_token FROM users WHERE id = $1', [post.user_id]
)
if (!author?.push_token) return
const { rows: [{ count }] } = await db.query(
'SELECT COUNT(*) FROM resonances WHERE post_id = $1', [postId]
)
const cnt = parseInt(count)
// Only notify at milestones to avoid spam
if (![1, 5, 10, 25, 50].includes(cnt)) return
// Import and send push — lazy import to avoid circular deps
const { sendPushToToken } = await import('../push.js')
await sendPushToToken(author.push_token, {
title: 'nightly',
body: cnt === 1
? 'Jemand wurde von deinem Gedanken getroffen 🫀'
: `${cnt} Menschen wurden von deinem Gedanken getroffen 🫀`
})
} catch (_) {
// Non-critical
}
}
function formatPost(row) {
return {
id: row.id,
content: row.content,
mood: row.mood ?? null,
isAnonymous: row.is_anonymous,
createdAt: row.created_at,
nightOf: row.created_at,
resonanceCount: row.resonance_count,
hasResonated: row.has_resonated,
commentCount: 0,
author: row.author_id ? {
id: row.author_id,
username: row.username,
displayName: row.display_name,
avatarURL: row.avatar_url,
followerCount: 0, followingCount: 0, postCount: 0,
isFollowing: row.is_following
} : null
}
}
export default router
+82
View File
@@ -0,0 +1,82 @@
import { Router } from 'express'
import { db } from '../index.js'
import { authenticate } from '../middleware/auth.js'
const router = Router()
router.use(authenticate)
router.get('/me', async (req, res) => {
const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [req.user.userId])
if (!rows[0]) return res.status(404).json({ message: 'Nicht gefunden' })
res.json(formatUser(rows[0]))
})
router.get('/:id/posts', async (req, res) => {
const { rows } = await db.query(`
SELECT p.*, u.username, u.display_name, u.avatar_url
FROM posts p JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1 ORDER BY p.created_at DESC LIMIT 50
`, [req.params.id])
res.json(rows.map(row => ({
id: row.id, content: row.content, imageURL: row.image_url,
createdAt: row.created_at, nightOf: row.created_at,
reactions: {}, myReaction: null, commentCount: 0,
author: {
id: row.user_id, username: row.username, displayName: row.display_name,
avatarURL: row.avatar_url, followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
}
})))
})
router.get('/:id/streak', async (req, res) => {
// Count consecutive nights (window = 25 AM, normalized to "day before 2 AM")
const { rows } = await db.query(`
WITH nights AS (
SELECT DISTINCT DATE(created_at - INTERVAL '2 hours') AS night
FROM posts WHERE user_id = $1
ORDER BY night DESC
),
numbered AS (
SELECT night, ROW_NUMBER() OVER (ORDER BY night DESC) AS rn FROM nights
)
SELECT COUNT(*) AS streak FROM numbered
WHERE night = (CURRENT_DATE - (rn - 1) * INTERVAL '1 day')
`, [req.params.id])
res.json({ streak: parseInt(rows[0]?.streak ?? 0) })
})
router.post('/me/push-token', async (req, res) => {
const { token } = req.body ?? {}
if (!token) return res.status(400).json({ message: 'Token fehlt' })
await db.query('UPDATE users SET push_token = $1 WHERE id = $2', [token, req.user.userId])
res.json({ ok: true })
})
router.post('/:id/follow', async (req, res) => {
if (req.params.id === req.user.userId) {
return res.status(400).json({ message: 'Du kannst dir selbst nicht folgen' })
}
await db.query(
'INSERT INTO follows (follower_id, following_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[req.user.userId, req.params.id]
)
res.json({ ok: true })
})
router.delete('/:id/follow', async (req, res) => {
await db.query(
'DELETE FROM follows WHERE follower_id = $1 AND following_id = $2',
[req.user.userId, req.params.id]
)
res.json({ ok: true })
})
function formatUser(u) {
return {
id: u.id, username: u.username, displayName: u.display_name,
bio: u.bio, avatarURL: u.avatar_url,
followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false
}
}
export default router
+96
View File
@@ -0,0 +1,96 @@
import apn from 'node-apn'
import { db, redis } from './index.js'
const REDIS_PING_KEY = 'nightly:ping_time'
let apnProvider = null
function initAPNs() {
if (!process.env.APNS_KEY_PATH) {
console.warn('APNs not configured — push notifications disabled')
return null
}
return 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'
})
}
// Pick a random minute for tonight's ping (between 02:00 and 04:30)
// Store in Redis so all instances agree
async function getTonightsPingTime() {
const stored = await redis.get(REDIS_PING_KEY)
if (stored) return JSON.parse(stored)
const hour = 2 + Math.floor(Math.random() * 2) // 2 or 3 AM
const minute = Math.floor(Math.random() * 60)
const pingTime = { hour, minute }
// Expire at 6 AM (well past window)
const ttl = secondsUntil(6, 0)
await redis.set(REDIS_PING_KEY, JSON.stringify(pingTime), { EX: ttl })
return pingTime
}
function secondsUntil(targetHour, targetMinute) {
const now = new Date()
const target = new Date(now)
target.setHours(targetHour, targetMinute, 0, 0)
if (target <= now) target.setDate(target.getDate() + 1)
return Math.floor((target - now) / 1000)
}
export function schedulePing() {
apnProvider = initAPNs()
// Check every minute
setInterval(async () => {
const { hour, minute } = await getTonightsPingTime()
const now = new Date()
if (now.getHours() === hour && now.getMinutes() === minute) {
await sendPing()
}
}, 60_000)
}
async function sendPing() {
if (!apnProvider) return
const { rows } = await db.query(
'SELECT push_token FROM users WHERE push_token IS NOT NULL'
)
if (rows.length === 0) return
const 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: '3 Stunden. Kein Filter. Nur du und deine Gedanken.' },
{ title: 'nightly', body: 'Was würdest du sagen, wenn niemand es sehen würde?' },
]
const msg = messages[Math.floor(Math.random() * messages.length)]
let sent = 0
for (const { push_token } of rows) {
const note = new apn.Notification()
note.expiry = Math.floor(Date.now() / 1000) + 3 * 3600
note.badge = 1
note.sound = 'default'
note.alert = msg
note.topic = process.env.APNS_BUNDLE_ID
note.payload = { type: 'nightly_ping' }
try {
await apnProvider.send(note, push_token)
sent++
} catch (err) {
console.error(`APNs send failed for token ${push_token}:`, err.message)
}
}
console.log(`[scheduler] Ping sent to ${sent}/${rows.length} users`)
}
+66
View File
@@ -0,0 +1,66 @@
services:
api:
build: ./api
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgres://nightly:${DB_PASSWORD}@db:5432/nightly
REDIS_URL: redis://redis:6379
NODE_ENV: production
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- internal
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
- ./api/src/db/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql
environment:
POSTGRES_DB: nightly
POSTGRES_USER: nightly
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nightly"]
interval: 5s
timeout: 5s
retries: 10
networks:
- internal
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- internal
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- internal
volumes:
postgres_data:
redis_data:
caddy_data:
caddy_config:
networks:
internal:
driver: bridge