Worker- und PDF-Dienst

Der Worker hängt am Discord-Gateway und schreibt alle 30 Sekunden sein
Lebenszeichen in die Datenbank, damit /api/health im Web-Container den
Gateway-Zustand kennt, ohne selbst mit Discord zu sprechen. healthcheck.js
prüft genau dieses Lebenszeichen. Der eigentliche Rollen-Abgleich kommt in
Phase 1.

Der PDF-Dienst rendert die Druckansicht über Playwright. Kein PDF-Layout von
Hand, die Gestaltung kommt aus @media print der Seite selbst. Er kennt keine
Rechte: wer darf, entscheidet die Web-App, bevor sie hier anfragt, und schickt
das Sitzungs-Cookie des Abrufers mit, damit die gerenderte Seite dessen
Freigabestufe sieht.
This commit is contained in:
Harlekin
2026-09-01 00:01:49 +02:00
parent ade977538d
commit 8375d1cb03
13 changed files with 374 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
import { Client, GatewayIntentBits, Events } from 'discord.js'
import { createPrismaClient } from '@afis/db'
import { loadEnv } from './env.js'
import { writeHeartbeat } from './heartbeat.js'
/**
* Rollen-Abgleich-Worker. Haengt am Discord-Gateway und haelt die
* Guild-Mitgliedschaft in Postgres aktuell, damit die Web-App nie live bei
* Discord anfragen muss. Siehe docs/02-architektur.md.
*
* Phase 0 baut das Geruest: Verbindung, Lebenszeichen, sauberes Herunterfahren.
* Der eigentliche Abgleich (Ereignisse, Voll-Abgleich, pg_notify) kommt in Phase 1.
*/
async function main(): Promise<void> {
const env = loadEnv()
const db = createPrismaClient(env.databaseUrl)
const client = new Client({
// Server Members Intent muss im Developer Portal aktiviert sein, sonst
// liefert der Bot keine Rollen. Siehe docs/03-auth-und-rechte.md.
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
})
let gatewayReady = false
client.once(Events.ClientReady, (ready) => {
gatewayReady = true
console.log(`[worker] am Gateway als ${ready.user.tag}`)
})
client.on(Events.ShardDisconnect, (_event, shardId) => {
gatewayReady = false
console.warn(`[worker] Verbindung zu Shard ${shardId} verloren`)
})
client.on(Events.ShardResume, () => {
gatewayReady = true
console.log('[worker] Verbindung wiederhergestellt')
})
client.on(Events.Error, (error) => {
console.error('[worker] Gateway-Fehler', error)
})
const heartbeat = setInterval(() => {
void writeHeartbeat(
db,
gatewayReady ? 'up' : 'degraded',
gatewayReady ? undefined : 'Gateway nicht verbunden',
).catch((error: unknown) => console.error('[worker] Lebenszeichen fehlgeschlagen', error))
}, env.heartbeatIntervalMs)
async function shutdown(signal: string): Promise<void> {
console.log(`[worker] ${signal}, fahre herunter`)
clearInterval(heartbeat)
await writeHeartbeat(db, 'down', 'planmäßig beendet').catch(() => undefined)
await client.destroy()
await db.$disconnect()
process.exit(0)
}
process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
await writeHeartbeat(db, 'degraded', 'Start läuft')
await client.login(env.discordBotToken)
}
main().catch((error: unknown) => {
console.error('[worker] Start fehlgeschlagen', error)
process.exit(1)
})