Files
planer/src/server/integration/aufnahme.ts
T
Florian WildeandClaude Opus 5 6dad3331e2 feat: Zammad, Telegram und den Spiegel anbinden
Zammad liest ueber die Suche und schreibt nichts zurueck. Telegram holt
per getUpdates statt per Webhook — der Planer soll auch hinter einem
Anschluss ohne feste Adresse laufen. Der Spiegel schickt Belegungen ohne
Titel und nur die ausdruecklich markierten Aufgaben.

Wer dem Telegram-Bot schreibt, ohne im Adressbuch zu stehen, bekommt
keine Antwort. Die einzige Ausnahme ist ein gueltiger Kopplungscode: Er
ist der Beweis, dass jemand mit Zugang zur Anwendung diesen Chat will.
Ohne ihn muesste man die Chatkennung von Hand eintragen — und um sie zu
erfahren, muesste der Bot Unbekannten antworten.

Jede Sendung des Spiegels traegt eine Signatur ueber den Rumpf und gilt
fuenf Minuten. Der Empfaenger antwortet auf jeden Fehlschlag gleich:
falsches Geheimnis, verstellte Uhr oder Unsinn im Inhalt sind von aussen
nicht zu unterscheiden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:03 +02:00

198 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'server-only'
import type { Quelle } from '@/generated/prisma/enums'
import { uebernahmeEntscheiden, type Bestand, type Fund } from '@/lib/integration'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { jetzt } from '@/server/zeitgeber'
/**
* Die gemeinsame Aufnahmestelle für alle Integrationen.
*
* Jede Integration liefert eine Liste von Funden; was daraus wird, entscheidet
* für alle dieselbe Stelle. Damit verhält sich Zammad wie Telegram wie der
* Spiegel — und eine neue Integration muss nur noch abholen können.
*
* Hier hängt später auch der Wartungsplaner ein: Er braucht eine Funktion, die
* seine Daten in `Fund` übersetzt, sonst nichts.
*/
export type Aufnahmebericht = {
angelegt: number
aktualisiert: number
geschlossen: number
unveraendert: number
}
const LEER: Aufnahmebericht = {
angelegt: 0,
aktualisiert: 0,
geschlossen: 0,
unveraendert: 0,
}
/** Wer die Aufnahme angestoßen hat – für das Protokoll. */
export type Anstoss = { id: string | null; email: string }
/**
* Nimmt eine Runde Funde auf.
*
* Anschließend gilt: Zu jedem Fund gibt es genau eine Aufgabe mit
* `[quelle, ref]`, und keine Aufgabe wurde in Feldern angefasst, die hier
* gepflegt werden statt drüben.
*/
export async function fundeAufnehmen(
quelle: Quelle,
funde: readonly Fund[],
anstoss: Anstoss,
): Promise<Aufnahmebericht> {
const bericht = { ...LEER }
const zeitpunkt = jetzt()
for (const fund of funde) {
const vorhanden = await db.aufgabe.findUnique({
where: { quelle_quelleRef: { quelle, quelleRef: fund.ref } },
select: {
id: true,
titel: true,
notiz: true,
quelleUrl: true,
faelligAm: true,
prioritaet: true,
aufwandMinuten: true,
status: true,
},
})
const bestand: Bestand | null = vorhanden
? {
titel: vorhanden.titel,
notiz: vorhanden.notiz,
quelleUrl: vorhanden.quelleUrl,
faelligAm: vorhanden.faelligAm,
prioritaet: vorhanden.prioritaet,
aufwandMinuten: vorhanden.aufwandMinuten,
erledigt: vorhanden.status === 'ERLEDIGT' || vorhanden.status === 'VERWORFEN',
}
: null
const entscheidung = uebernahmeEntscheiden(fund, bestand)
if (entscheidung.tun === 'nichts') {
bericht.unveraendert++
continue
}
if (entscheidung.tun === 'anlegen') {
const angelegt = await db.aufgabe.create({
data: {
titel: fund.titel,
notiz: fund.notiz ?? null,
quelle,
quelleRef: fund.ref,
quelleUrl: fund.url ?? null,
faelligAm: fund.faelligAm ?? null,
...(fund.prioritaet ? { prioritaet: fund.prioritaet } : {}),
...(fund.aufwandMinuten ? { aufwandMinuten: fund.aufwandMinuten } : {}),
},
select: { id: true },
})
bericht.angelegt++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.angelegt',
objektTyp: 'Aufgabe',
objektId: angelegt.id,
// Der Titel steht schon in der Aufgabe; hier genügt die Herkunft.
details: { quelle, ref: fund.ref },
})
continue
}
if (entscheidung.tun === 'schliessen') {
await db.aufgabe.update({
where: { id: vorhanden?.id ?? '' },
data: { status: 'ERLEDIGT', erledigtAm: zeitpunkt },
})
bericht.geschlossen++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.geschlossen',
objektTyp: 'Aufgabe',
objektId: vorhanden?.id ?? null,
details: { quelle, ref: fund.ref },
})
continue
}
await db.aufgabe.update({
where: { id: vorhanden?.id ?? '' },
data: entscheidung.felder,
})
bericht.aktualisiert++
await auditSchreiben({
akteur: anstoss,
aktion: 'integration.aktualisiert',
objektTyp: 'Aufgabe',
objektId: vorhanden?.id ?? null,
details: { quelle, ref: fund.ref, felder: Object.keys(entscheidung.felder) },
})
}
return bericht
}
// --- Zustand je Integration ------------------------------------------------
/** Liest den Merker, an dem eine Integration weitermacht. */
export async function merkerLesen(quelle: Quelle): Promise<string | null> {
const zeile = await db.integrationszustand.findUnique({
where: { quelle },
select: { merker: true },
})
return zeile?.merker ?? null
}
/** Hält fest, dass ein Lauf geklappt hat. */
export async function laufGeglueckt(quelle: Quelle, merker?: string): Promise<void> {
const zeitpunkt = jetzt()
const werte = {
letzterLauf: zeitpunkt,
letzterErfolg: zeitpunkt,
letzterFehler: null,
fehlversuche: 0,
...(merker === undefined ? {} : { merker }),
}
await db.integrationszustand.upsert({
where: { quelle },
update: werte,
create: { quelle, ...werte },
})
}
/**
* Hält einen Fehlschlag fest und zählt hoch.
*
* Nur die Meldung, nie die Antwort des fremden Systems: Darin stehen gern
* Tokens, Kopfzeilen und ganze Datensätze.
*/
export async function laufGescheitert(quelle: Quelle, fehler: unknown): Promise<number> {
const meldung = fehler instanceof Error ? fehler.message : 'unbekannter Fehler'
const zeitpunkt = jetzt()
const zeile = await db.integrationszustand.upsert({
where: { quelle },
update: {
letzterLauf: zeitpunkt,
letzterFehler: meldung.slice(0, 500),
fehlversuche: { increment: 1 },
},
create: {
quelle,
letzterLauf: zeitpunkt,
letzterFehler: meldung.slice(0, 500),
fehlversuche: 1,
},
select: { fehlversuche: true },
})
return zeile.fehlversuche
}