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>
198 lines
5.6 KiB
TypeScript
198 lines
5.6 KiB
TypeScript
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
|
||
}
|