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 @@
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
# Replace with your actual domain
|
||||
api.nightly.app {
|
||||
reverse_proxy api:3000 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'))
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: 3–20 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = 2–5 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
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user