import type { Client } from 'discord.js' import type { PrismaClient } from '@afis/db' /** * Zustellung der Benachrichtigungen. * * Der Worker ist hier **nur Transport**. Wer etwas erfahren darf und was davon * gezeigt werden darf, hat die Web-App beim Einstellen in den Postausgang * entschieden — sie hat die Sichtbarkeitsprüfung, er hat sie nicht. In `meta` * steht deshalb ausschliesslich, was stehen darf: ein Titel liegt dort nur, * wenn er auch nach Discord darf. Siehe docs/06-chat-und-realtime.md. * * Der Dauerläufer holt in zwei Takten: sofort, wenn `notification_queued` * kommt, und alle 30 Sekunden als Sicherheitsnetz. Kein Einmal-Container, * siehe CLAUDE.md, harte Regel 8. */ /** Nach so vielen Fehlversuchen wird eine Zeile liegengelassen. */ const MAX_ATTEMPTS = 5 /** Wie viele Zeilen ein Durchgang höchstens abarbeitet. */ const BATCH = 25 type Meta = { caseNumber?: string actorName?: string title?: string | null channelKey?: string } /** * Baut den Satz. Bewusst knapp und ohne jeden Inhalt aus der Akte — der Link * führt zur Anwendung, und die prüft dann selbst. */ export function composeMessage( kind: string, meta: Meta, appUrl: string, ): string | null { const wer = meta.actorName ?? 'Jemand' if (meta.caseNumber) { const link = `${appUrl.replace(/\/$/, '')}/faelle/${meta.caseNumber}` const bezeichnung = meta.title ? `**${meta.caseNumber}** — ${meta.title}` : `**${meta.caseNumber}**` if (kind === 'entry.new') return `Neuer Eintrag in ${bezeichnung} · ${wer} · ${link}` if (kind === 'chat.mention') return `${wer} hat dich in ${bezeichnung} erwähnt · ${link}` if (kind === 'chat.new') return `Neue Nachricht im Aktenchat ${bezeichnung} · ${wer} · ${link}` if (kind === 'case.status') return `Status geändert in ${bezeichnung} · ${wer} · ${link}` return null } if (meta.channelKey && kind === 'chat.mention') { const link = `${appUrl.replace(/\/$/, '')}/kanaele/${meta.channelKey}` return `${wer} hat dich in #${meta.channelKey} erwähnt · ${link}` } return null } export async function dispatchOutbox( db: PrismaClient, client: Client, options: { appUrl: string; adminChannelId?: string }, ): Promise { const offen = await db.notificationOutbox.findMany({ where: { sentAt: null, attempts: { lt: MAX_ATTEMPTS } }, orderBy: { id: 'asc' }, take: BATCH, }) let zugestellt = 0 for (const eintrag of offen) { const text = composeMessage(eintrag.kind, (eintrag.meta ?? {}) as Meta, options.appUrl) // Nichts zu sagen ist kein Fehler: die Zeile wird abgehakt, damit sie den // Postausgang nicht verstopft. if (!text) { await db.notificationOutbox.update({ where: { id: eintrag.id }, data: { sentAt: new Date(), lastError: 'nichts zu melden' }, }) continue } try { if (eintrag.recipientId) { const nutzer = await client.users.fetch(eintrag.recipientId) await nutzer.send(text) } else if (options.adminChannelId) { const kanal = await client.channels.fetch(options.adminChannelId) if (kanal?.isTextBased() && 'send' in kanal) { await kanal.send(text) } } else { // Kein allgemeiner Kanal eingerichtet: die Zeile ist erledigt, ohne // dass etwas hinausging. await db.notificationOutbox.update({ where: { id: eintrag.id }, data: { sentAt: new Date(), lastError: 'kein Kanal konfiguriert' }, }) continue } await db.notificationOutbox.update({ where: { id: eintrag.id }, data: { sentAt: new Date(), attempts: { increment: 1 } }, }) zugestellt++ } catch (error) { // Eine geschlossene Direktnachricht ist keine Störung, sondern eine // Entscheidung des Empfängers. Sie wird gezählt und irgendwann // aufgegeben, aber nie laut. await db.notificationOutbox.update({ where: { id: eintrag.id }, data: { attempts: { increment: 1 }, lastError: error instanceof Error ? error.message.slice(0, 200) : 'unbekannt', }, }) } } return zugestellt }