5 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 bc60e5f864 feat: Integrationen takten und Telegram als dritten Meldeweg
Telegram alle zwei Minuten, Zammad und Spiegel alle fuenfzehn. Wer dem
Bot etwas schickt, erwartet die Aufgabe gleich in der Liste; in Zammad
aendert sich dagegen nichts im Minutentakt.

Meldungen gehen jetzt auch per Telegram hinaus — mit einem Knopf zum
Abhaken, wenn es um genau eine Aufgabe geht. Eine klemmende Integration
meldet sich wie ein klemmender Kalender: erst nach dem dritten
Fehlversuch, dann aber deutlich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:05 +02:00
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
Florian WildeandClaude Opus 5 e4d3547457 feat: Aufnahmeregeln fuer Integrationen als reine Logik
Was aus einem Fund wird, entscheidet fuer alle Integrationen dieselbe
Stelle: anlegen, aktualisieren, schliessen oder nichts tun.

Gefuehrt werden nur Titel, Notiz, Verweis und Faelligkeit. Bereich,
Projekt, Prioritaet und geschaetzter Aufwand gehoeren der Planung — wer
ein Ticket einsortiert und schaetzt, will nicht, dass der naechste Lauf
das wieder wegraeumt.

Dazu die Regeln des Spiegels: Aufbau der Sendung und ihre Frist. Ohne
Frist liesse sich ein abgefangenes Paket beliebig oft erneut einspielen.

21 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:03:33 +02:00
Florian WildeandClaude Opus 5 7f8f505126 feat: Datenmodell und Umgebung fuer Integrationen
Ein Zustand je Quelle haelt fest, wie weit gelesen wurde und ob es
klappt. Der Merker ist bewusst ein freier Text: Zammad braucht darin
einen Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht.

Dazu die gespiegelte Belegung ohne Titel, das Adressbuch der erlaubten
Telegram-Chats und der Einmalcode, mit dem sich ein Chat anmeldet.

Welche Integration eine Instanz darf, entscheidet weiterhin INSTANZ. Ein
Zammad-Token in der Instanz daheim bleibt wirkungslos — das steht in den
Faehigkeiten, nicht in einer Einstellung, die sich verstellen liesse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:03:32 +02:00
Florian WildeandClaude Opus 5 ab1b898e44 Merge: Meilenstein 5 – Benachrichtigungen (0.5.0)
Der Planer sagt jetzt, was ansteht: Tagesueberblick am Morgen, faellige
Aufgaben, Vorlauf vor einem Zeitblock und ein Kalender, der sich nicht
abgleichen laesst. Per Web-Push, per E-Mail oder beides.

Ab Werk meldet er gar nichts. Eine Anwendung, die zu oft meldet, wird
stummgeschaltet und ist dann wertlos — deshalb ist die Einrichtung ein
bewusster Handgriff, dafuer kommt dann auch nur, was gewollt ist.

Der Nachweis einer Meldung wird vor dem Senden geschrieben: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.

Geprueft vor dem Merge: npm run pruefen mit 280 Unit-Tests, 47
Integrationspruefungen, Migrationstest und 20 E2E-Tests. Der Versandweg ist
gegen die Logik geprueft, nicht gegen echte Dienste — dafuer fehlen
VAPID-Schluessel und SMTP-Zugang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:43:55 +02:00
15 changed files with 1711 additions and 13 deletions
+23
View File
@@ -97,6 +97,29 @@ SMTP_ABSENDER=
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
SMTP_TLS=false
# --- Integrationen ---------------------------------------------------------
# Alles freiwillig. Was fehlt, erscheint unter "Integrationen" als nicht
# eingerichtet; der Rest laeuft weiter.
# Welche Integration eine Instanz ueberhaupt darf, entscheidet INSTANZ:
# Zammad und Wartungsplaner nur "firma", Telegram nur "daheim".
# Zammad (nur firma). Token in Zammad unter Profil > Token-Zugriff anlegen;
# er sieht genau das, was der zugehoerige Benutzer sehen darf.
ZAMMAD_URL=
ZAMMAD_TOKEN=
# Suchausdruck in Zammads Syntax. Was darauf passt, wird zur Aufgabe.
ZAMMAD_ABFRAGE=owner_id:me AND (state.name:new OR state.name:open)
# Telegram (nur daheim). Bot bei @BotFather anlegen, Token hier eintragen.
# Welche Chats mitreden duerfen, wird in der Anwendung gekoppelt.
TELEGRAM_TOKEN=
# Spiegel: firma sendet, daheim empfaengt. Dasselbe Geheimnis auf beiden
# Seiten, mindestens 32 Zeichen: openssl rand -base64 32
SPIEGEL_GEHEIMNIS=
# Nur auf der sendenden Seite (firma): die Adresse der anderen Instanz.
SPIEGEL_ZIEL=
# --- Sonstiges -------------------------------------------------------------
# Zeitzone fuer Anzeigen und Tagesgrenzen.
TZ=Europe/Berlin
@@ -0,0 +1,62 @@
-- AlterTable
ALTER TABLE "aufgabe" ADD COLUMN "spiegeln" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "benachrichtigungseinstellung" ADD COLUMN "perTelegram" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "integrationszustand" (
"id" TEXT NOT NULL,
"quelle" "Quelle" NOT NULL,
"merker" TEXT,
"letzterLauf" TIMESTAMP(3),
"letzterErfolg" TIMESTAMP(3),
"letzterFehler" TEXT,
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "integrationszustand_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "spiegelbelegung" (
"id" TEXT NOT NULL,
"quelleRef" TEXT NOT NULL,
"beginn" TIMESTAMP(3) NOT NULL,
"ende" TIMESTAMP(3) NOT NULL,
"empfangenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "spiegelbelegung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "telegram_chat" (
"id" TEXT NOT NULL,
"chatId" BIGINT NOT NULL,
"bezeichnung" TEXT,
"benutzerId" TEXT,
"meldungen" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zuletztBenutzt" TIMESTAMP(3),
CONSTRAINT "telegram_chat_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "integrationszustand_quelle_key" ON "integrationszustand"("quelle");
-- CreateIndex
CREATE UNIQUE INDEX "spiegelbelegung_quelleRef_key" ON "spiegelbelegung"("quelleRef");
-- CreateIndex
CREATE INDEX "spiegelbelegung_beginn_idx" ON "spiegelbelegung"("beginn");
-- CreateIndex
CREATE UNIQUE INDEX "telegram_chat_chatId_key" ON "telegram_chat"("chatId");
-- CreateIndex
CREATE INDEX "telegram_chat_benutzerId_idx" ON "telegram_chat"("benutzerId");
-- AddForeignKey
ALTER TABLE "telegram_chat" ADD CONSTRAINT "telegram_chat_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "telegram_kopplung" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"benutzerId" TEXT NOT NULL,
"gueltigBis" TIMESTAMP(3) NOT NULL,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "telegram_kopplung_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "telegram_kopplung_code_key" ON "telegram_kopplung"("code");
-- CreateIndex
CREATE INDEX "telegram_kopplung_gueltigBis_idx" ON "telegram_kopplung"("gueltigBis");
+90
View File
@@ -38,6 +38,7 @@ model Benutzer {
benachrichtigung Benachrichtigungseinstellung?
pushGeraete PushGeraet[]
meldungen Meldungsnachweis[]
telegramChats TelegramChat[]
@@index([aktiv])
@@map("benutzer")
@@ -222,6 +223,10 @@ model Aufgabe {
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
quelleRef String?
/// Ausdruecklich zum Spiegeln freigegeben. Nur was hier true ist, verlaesst
/// die Instanz mit Titel – alles andere bleibt, wo es ist.
spiegeln Boolean @default(false)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
@@ -394,6 +399,7 @@ model Benachrichtigungseinstellung {
anlaesse String @default("tagesueberblick,faelligkeit")
perPush Boolean @default(false)
perMail Boolean @default(false)
perTelegram Boolean @default(false)
/// Alles als Minuten seit Mitternacht, Europe/Berlin.
ueberblickUm Int @default(420)
@@ -448,3 +454,87 @@ model Meldungsnachweis {
@@index([gesendetAm])
@@map("meldungsnachweis")
}
/// Zustand einer Integration: wie weit sie gelesen hat und ob es klappt.
///
/// Eine Zeile je Quelle. Der Merker ist bewusst ein freier Text – jede
/// Integration legt darin ab, was sie zum Fortsetzen braucht: Zammad einen
/// Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht.
model Integrationszustand {
id String @id @default(cuid())
quelle Quelle @unique
/// Woher es weitergeht. Bedeutung haengt an der Quelle.
merker String?
letzterLauf DateTime?
letzterErfolg DateTime?
letzterFehler String?
/// Fehlschlaege in Folge; bei Erfolg wieder null.
fehlversuche Int @default(0)
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
@@map("integrationszustand")
}
/// Dienstliche Belegung, die aus der anderen Instanz kommt.
///
/// Bewusst ohne Titel: Daheim soll sehen, *dass* die Zeit weg ist, nicht
/// woran gearbeitet wird. Firmeninhalte verlassen die Firma nicht.
model Spiegelbelegung {
id String @id @default(cuid())
/// Kennung des Zeitblocks in der sendenden Instanz.
quelleRef String @unique
beginn DateTime
ende DateTime
empfangenAm DateTime @default(now())
@@index([beginn])
@@map("spiegelbelegung")
}
/// Ein Telegram-Chat, der mit dem Planer reden darf.
///
/// Wer nicht hier steht, wird verworfen – ein Bot-Token laesst sich erraten
/// oder abgreifen, die Chatkennung ist der zweite Riegel.
model TelegramChat {
id String @id @default(cuid())
/// Chatkennung bei Telegram; kann negativ sein (Gruppen).
chatId BigInt @unique
bezeichnung String?
benutzerId String?
/// Empfaengt dieser Chat auch Meldungen, nicht nur Aufgaben?
meldungen Boolean @default(true)
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
@@index([benutzerId])
@@map("telegram_chat")
}
/// Einmalcode, mit dem sich ein Telegram-Chat anmeldet.
///
/// Ohne ihn muesste die Chatkennung von Hand eingetragen werden – und um sie
/// zu erfahren, muesste der Planer auf Nachrichten Unbekannter antworten.
/// Genau das soll er nicht.
model TelegramKopplung {
id String @id @default(cuid())
code String @unique
benutzerId String
gueltigBis DateTime
erstelltAm DateTime @default(now())
@@index([gueltigBis])
@@map("telegram_kopplung")
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { auditSchreiben } from '@/server/audit'
import { sendungAufnehmen, sendungPruefen } from '@/server/integration/spiegel'
import { jetzt } from '@/server/zeitgeber'
export const dynamic = 'force-dynamic'
/**
* Der Eingang des Spiegels — nur in der Instanz `daheim`.
*
* Hier meldet sich kein Mensch an: Die Gegenstelle weist sich über eine
* Signatur aus, die sie mit dem gemeinsamen Geheimnis über den Rumpf bildet.
*
* Jede Zurückweisung sieht gleich aus. Ob das Geheimnis falsch war, die Uhr
* verstellt oder der Inhalt Unsinn — von außen ist das nicht zu unterscheiden,
* und genau so soll es sein.
*/
export async function POST(anfrage: Request) {
// Der Rumpf muss als Text gelesen werden: Die Signatur gilt für genau diese
// Zeichen, nicht für das, was ein JSON-Parser daraus und wieder zurück macht.
const koerper = await anfrage.text()
const signatur = anfrage.headers.get('X-Planer-Signatur')
const geprueft = sendungPruefen(koerper, signatur, jetzt())
if (!geprueft.ok) {
return NextResponse.json({ fehler: 'Abgelehnt.' }, { status: 401 })
}
const bericht = await sendungAufnehmen(geprueft.sendung, {
id: null,
email: 'spiegel@intern',
})
await auditSchreiben({
akteur: { id: null, email: 'spiegel@intern' },
aktion: 'spiegel.empfangen',
objektTyp: 'Spiegelbelegung',
objektId: null,
// Nur Zahlen: Was gespiegelt wurde, steht in den Datensätzen selbst.
details: bericht,
})
return NextResponse.json({ ok: true, ...bericht })
}
+11 -1
View File
@@ -47,6 +47,7 @@ export type Einstellung = {
anlaesse: readonly Anlass[]
perPush: boolean
perMail: boolean
perTelegram: boolean
/** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */
ueberblickUm: number
/** Ruhezeit: von hier bis … nichts melden. */
@@ -68,6 +69,7 @@ export const VOREINSTELLUNG: Einstellung = {
anlaesse: ['tagesueberblick', 'faelligkeit'],
perPush: false,
perMail: false,
perTelegram: false,
ueberblickUm: 7 * 60,
ruheVon: 20 * 60,
ruheBis: 7 * 60,
@@ -103,6 +105,13 @@ export type Meldung = {
text: string
/** Wohin der Klick führt. */
pfad: string
/**
* Die Aufgabe, um die es geht — sofern es genau eine ist.
*
* Nur dann lässt sich unter die Meldung ein Knopf hängen, der sie abhakt.
* Ein Tagesüberblick hat keine.
*/
objektId?: string
}
/**
@@ -124,7 +133,7 @@ export function sendenEntscheiden(
if (!einstellung.anlaesse.includes(meldung.anlass)) {
return { senden: false, grund: 'abgeschaltet' }
}
if (!einstellung.perPush && !einstellung.perMail) {
if (!einstellung.perPush && !einstellung.perMail && !einstellung.perTelegram) {
return { senden: false, grund: 'kein-weg' }
}
if (bereitsGemeldet.has(meldung.schluessel)) {
@@ -202,6 +211,7 @@ export const einstellungSchema = z.object({
anlaesse: z.array(z.enum(ANLAESSE)).default([]),
perPush: z.coerce.boolean().default(false),
perMail: z.coerce.boolean().default(false),
perTelegram: z.coerce.boolean().default(false),
ueberblickUm: uhrzeit,
ruheVon: uhrzeit,
ruheBis: uhrzeit,
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, test } from 'vitest'
import {
alsTitel,
sendungFrisch,
spiegelSendung,
TITEL_MAX,
uebernahmeEntscheiden,
type Bestand,
type Fund,
} from '@/lib/integration'
function fund(teile: Partial<Fund> = {}): Fund {
return { ref: 'T-1', titel: 'Drucker klemmt', ...teile }
}
function bestand(teile: Partial<Bestand> = {}): Bestand {
return {
titel: 'Drucker klemmt',
notiz: null,
quelleUrl: null,
faelligAm: null,
prioritaet: 3,
aufwandMinuten: null,
erledigt: false,
...teile,
}
}
describe('uebernahmeEntscheiden', () => {
test('legt an, was es hier noch nicht gibt', () => {
expect(uebernahmeEntscheiden(fund(), null)).toEqual({ tun: 'anlegen' })
})
test('legt nichts an, was drüben schon erledigt ist', () => {
expect(uebernahmeEntscheiden(fund({ erledigt: true }), null)).toEqual({
tun: 'nichts',
grund: 'schon-erledigt',
})
})
test('erkennt den unveränderten Fall', () => {
expect(uebernahmeEntscheiden(fund(), bestand())).toEqual({
tun: 'nichts',
grund: 'unveraendert',
})
})
test('übernimmt einen geänderten Titel', () => {
const entscheidung = uebernahmeEntscheiden(
fund({ titel: 'Drucker klemmt weiterhin' }),
bestand(),
)
expect(entscheidung).toEqual({
tun: 'aktualisieren',
felder: { titel: 'Drucker klemmt weiterhin' },
})
})
test('übernimmt eine neue Fälligkeit', () => {
const faelligAm = new Date('2026-04-01T00:00:00.000Z')
expect(uebernahmeEntscheiden(fund({ faelligAm }), bestand())).toEqual({
tun: 'aktualisieren',
felder: { faelligAm },
})
})
test('vergleicht Datumswerte nach Zeitpunkt, nicht nach Objekt', () => {
const faelligAm = new Date('2026-04-01T00:00:00.000Z')
const entscheidung = uebernahmeEntscheiden(
fund({ faelligAm: new Date(faelligAm.getTime()) }),
bestand({ faelligAm }),
)
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
})
test('rührt Priorität und Aufwand nicht an', () => {
// Beides wird hier geschätzt, nicht drüben. Ein Abgleich, der die
// Schätzung wegräumt, macht die Wochenplanung kaputt.
const entscheidung = uebernahmeEntscheiden(
fund({ prioritaet: 1, aufwandMinuten: 30 }),
bestand({ prioritaet: 4, aufwandMinuten: 90 }),
)
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
})
test('schließt, was drüben erledigt wurde', () => {
expect(uebernahmeEntscheiden(fund({ erledigt: true }), bestand())).toEqual({
tun: 'schliessen',
})
})
test('schließt nicht zweimal', () => {
expect(
uebernahmeEntscheiden(fund({ erledigt: true }), bestand({ erledigt: true })),
).toEqual({ tun: 'nichts', grund: 'schon-erledigt' })
})
test('holt eine hier erledigte Aufgabe zurück, wenn sie drüben wieder offen ist', () => {
const entscheidung = uebernahmeEntscheiden(fund(), bestand({ erledigt: true }))
// Das Ursprungssystem führt den Zustand; wer dort neu öffnet, meint es so.
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
})
})
describe('alsTitel', () => {
test('lässt kurze Texte in Ruhe', () => {
expect(alsTitel('Angebot schreiben')).toEqual({
titel: 'Angebot schreiben',
rest: null,
})
})
test('macht aus Zeilenumbrüchen Leerzeichen', () => {
expect(alsTitel('Angebot\n\nschreiben ')).toEqual({
titel: 'Angebot schreiben',
rest: null,
})
})
test('kürzt lange Texte und hebt den Rest auf', () => {
const lang = `${'wort '.repeat(60)}ende`
const { titel, rest } = alsTitel(lang)
expect(titel.length).toBeLessThanOrEqual(TITEL_MAX + 1)
expect(titel.endsWith('…')).toBe(true)
expect(rest).not.toBeNull()
expect(rest?.endsWith('ende')).toBe(true)
})
test('trennt nicht mitten im Wort, wenn eine Fuge in der Nähe ist', () => {
const lang = `${'a'.repeat(60)} ${'b'.repeat(80)}`
const { titel } = alsTitel(lang)
expect(titel).toBe(`${'a'.repeat(60)}…`)
})
test('trennt hart, wenn es keine Fuge gibt', () => {
const { titel } = alsTitel('x'.repeat(200))
expect(titel).toBe(`${'x'.repeat(TITEL_MAX)}…`)
})
})
describe('sendungFrisch', () => {
const jetzt = new Date('2026-03-17T08:20:00.000Z')
test('nimmt an, was gerade erst losgeschickt wurde', () => {
expect(sendungFrisch(new Date('2026-03-17T08:18:00.000Z'), jetzt)).toBe(true)
})
test('lehnt ein altes Paket ab', () => {
expect(sendungFrisch(new Date('2026-03-17T08:10:00.000Z'), jetzt)).toBe(false)
})
test('lehnt auch ein Paket aus der Zukunft ab', () => {
// Eine verstellte Uhr auf der Gegenseite ist genauso verdächtig.
expect(sendungFrisch(new Date('2026-03-17T08:40:00.000Z'), jetzt)).toBe(false)
})
})
describe('spiegelSendung', () => {
test('nimmt ein vollständiges Paket an', () => {
const gelesen = spiegelSendung.safeParse({
gesendetAm: '2026-03-17T08:20:00.000Z',
belegungen: [
{
ref: 'zb-1',
beginn: '2026-03-17T09:00:00.000Z',
ende: '2026-03-17T10:00:00.000Z',
},
],
aufgaben: [{ ref: 'a-1', titel: 'Angebot schreiben' }],
})
expect(gelesen.success).toBe(true)
expect(gelesen.success && gelesen.data.aufgaben[0]?.erledigt).toBe(false)
})
test('weist ein Paket ohne Zeitstempel ab', () => {
const gelesen = spiegelSendung.safeParse({ belegungen: [], aufgaben: [] })
expect(gelesen.success).toBe(false)
})
test('weist eine Belegung mit kaputtem Datum ab', () => {
const gelesen = spiegelSendung.safeParse({
gesendetAm: '2026-03-17T08:20:00.000Z',
belegungen: [{ ref: 'zb-1', beginn: 'morgen', ende: 'später' }],
aufgaben: [],
})
expect(gelesen.success).toBe(false)
})
})
+177
View File
@@ -0,0 +1,177 @@
import { z } from 'zod'
/**
* Gemeinsame Regeln für alle Integrationen.
*
* Der Kern jeder Integration ist derselbe: ein fremdes System liefert Zeug,
* daraus wird eine Aufgabe, und beim nächsten Lauf darf dieselbe Sache keine
* zweite Aufgabe ergeben. Was sich unterscheidet, ist allein das Abholen.
*
* Reine Logik ohne Datenbank, ohne Netz und ohne Systemzeit.
*/
/** Was eine Integration liefert, bevor daraus eine Aufgabe wird. */
export type Fund = {
/** Kennung beim Ursprungssystem. Bleibt über die Lebensdauer gleich. */
ref: string
titel: string
notiz?: string | null
url?: string | null
faelligAm?: Date | null
/** 1 = dringend, 5 = irgendwann. */
prioritaet?: number | null
aufwandMinuten?: number | null
/** Im Ursprungssystem abgeschlossen: die Aufgabe darf zu. */
erledigt?: boolean
}
/**
* Was beim Übernehmen eines Fundes passieren soll.
*
* Der heikle Teil ist nicht das Anlegen, sondern das zweite Mal: Eine Aufgabe,
* an der hier gearbeitet wurde, darf ein Abgleich nicht zurücksetzen.
*/
export type Uebernahme =
| { tun: 'anlegen' }
| { tun: 'aktualisieren'; felder: Partial<Bestand> }
| { tun: 'schliessen' }
| { tun: 'nichts'; grund: 'unveraendert' | 'von-hand-geaendert' | 'schon-erledigt' }
/** Der Stand, wie er in der Datenbank liegt. */
export type Bestand = {
titel: string
notiz: string | null
quelleUrl: string | null
faelligAm: Date | null
prioritaet: number
aufwandMinuten: number | null
erledigt: boolean
}
/**
* Felder, die das Ursprungssystem führt — und nur diese.
*
* Bereich, Projekt und Zeitblöcke gehören der Planung und werden nie aus
* einer Integration überschrieben. Wer ein Ticket einem Bereich zuordnet,
* will nicht, dass der nächste Lauf das wieder wegräumt.
*/
const GEFUEHRT = ['titel', 'notiz', 'quelleUrl', 'faelligAm'] as const
function gleich(a: unknown, b: unknown): boolean {
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
return a === b
}
export function uebernahmeEntscheiden(fund: Fund, bestand: Bestand | null): Uebernahme {
if (!bestand) {
// Ein Fund, der drüben schon erledigt ist, braucht hier nicht erst zu
// entstehen, nur um sofort wieder zuzugehen.
return fund.erledigt ? { tun: 'nichts', grund: 'schon-erledigt' } : { tun: 'anlegen' }
}
if (fund.erledigt) {
return bestand.erledigt
? { tun: 'nichts', grund: 'schon-erledigt' }
: { tun: 'schliessen' }
}
const neu: Partial<Bestand> = {}
const gewuenscht: Bestand = {
titel: fund.titel,
notiz: fund.notiz ?? null,
quelleUrl: fund.url ?? null,
faelligAm: fund.faelligAm ?? null,
prioritaet: bestand.prioritaet,
aufwandMinuten: bestand.aufwandMinuten,
erledigt: false,
}
for (const feld of GEFUEHRT) {
if (!gleich(bestand[feld], gewuenscht[feld])) {
// @ts-expect-error – der Index ist über GEFUEHRT eingegrenzt.
neu[feld] = gewuenscht[feld]
}
}
return Object.keys(neu).length === 0
? { tun: 'nichts', grund: 'unveraendert' }
: { tun: 'aktualisieren', felder: neu }
}
/**
* Ab wann ein Fehlschlag gemeldet wird — dieselbe Schwelle wie beim
* Kalenderabgleich. Ein Aussetzer ist keine Nachricht wert.
*/
export const INTEGRATION_FEHLER_AB_VERSUCH = 3
/**
* Kürzt einen Text auf eine Länge, die als Aufgabentitel taugt.
*
* Fremde Systeme kennen keine Grenze; ein Titel über mehrere Zeilen macht
* jede Liste unlesbar. Der Rest wandert in die Notiz, geht also nicht
* verloren.
*/
export const TITEL_MAX = 120
export function alsTitel(text: string): { titel: string; rest: string | null } {
const sauber = text.replace(/\s+/g, ' ').trim()
if (sauber.length <= TITEL_MAX) return { titel: sauber, rest: null }
// Lieber an einem Wort trennen als mitten hinein – aber nur, solange
// dabei nicht mehr als die Haelfte des Titels wegfaellt.
const schnitt = sauber.lastIndexOf(' ', TITEL_MAX)
const bei = schnitt >= TITEL_MAX / 2 ? schnitt : TITEL_MAX
return { titel: `${sauber.slice(0, bei).trimEnd()}…`, rest: sauber.slice(bei).trim() }
}
// --- Spiegel ---------------------------------------------------------------
/**
* Was von einer Instanz zur anderen geht.
*
* Belegungen tragen keinen Titel: Daheim soll sehen, *dass* die Zeit weg ist,
* nicht woran gearbeitet wird. Aufgaben gehen nur mit, wenn sie ausdrücklich
* dafür markiert sind.
*/
export const spiegelBelegung = z.object({
ref: z.string().min(1).max(64),
beginn: z.iso.datetime(),
ende: z.iso.datetime(),
})
export const spiegelAufgabe = z.object({
ref: z.string().min(1).max(64),
titel: z
.string()
.min(1)
.max(TITEL_MAX + 1),
faelligAm: z.iso.datetime().nullable().default(null),
erledigt: z.boolean().default(false),
})
export const spiegelSendung = z.object({
/** Wann der Sender das Paket geschnürt hat, für die Frische. */
gesendetAm: z.iso.datetime(),
belegungen: z.array(spiegelBelegung).max(500),
aufgaben: z.array(spiegelAufgabe).max(500),
})
export type SpiegelSendung = z.infer<typeof spiegelSendung>
/**
* Wie alt eine Sendung höchstens sein darf.
*
* Ohne Frist ließe sich ein abgefangenes Paket beliebig oft erneut
* einspielen. Fünf Minuten sind genug für eine träge Leitung und zu wenig
* für ein Archiv.
*/
export const SENDUNG_FRIST_MINUTEN = 5
export function sendungFrisch(
gesendetAm: Date,
jetzt: Date,
fristMinuten = SENDUNG_FRIST_MINUTEN,
): boolean {
const abstandMinuten = Math.abs(jetzt.getTime() - gesendetAm.getTime()) / 60_000
return abstandMinuten <= fristMinuten
}
+43 -3
View File
@@ -17,9 +17,13 @@ import {
tagesBeginn,
tagesEnde,
} from '@/lib/zeit'
import type { Quelle } from '@/generated/prisma/enums'
import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration'
import { db } from '@/server/db'
import { mailSenden } from '@/server/benachrichtigung/mail'
import { pushSenden } from '@/server/benachrichtigung/push'
import { telegramMeldung } from '@/server/integration/telegram'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
/**
@@ -46,6 +50,7 @@ export function alsEinstellung(
anlaesse: string
perPush: boolean
perMail: boolean
perTelegram: boolean
ueberblickUm: number
ruheVon: number
ruheBis: number
@@ -66,6 +71,7 @@ export function alsEinstellung(
anlaesse,
perPush: zeile.perPush,
perMail: zeile.perMail,
perTelegram: zeile.perTelegram,
ueberblickUm: zeile.ueberblickUm,
ruheVon: zeile.ruheVon,
ruheBis: zeile.ruheBis,
@@ -86,6 +92,7 @@ async function empfaengerLaden(): Promise<BenutzerMitEinstellung[]> {
anlaesse: true,
perPush: true,
perMail: true,
perTelegram: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
@@ -141,6 +148,9 @@ export async function meldungZustellen(
if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung)
if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung)
if (benutzer.einstellung.perTelegram && umgebung().telegramAktiv) {
await telegramMeldung(benutzer.id, meldung)
}
return true
}
@@ -232,6 +242,8 @@ async function faelligkeitenBauen(zeitpunkt: Date): Promise<Meldung[]> {
titel: 'Heute fällig',
text: aufgabe.titel,
pfad: '/aufgaben',
// Genau eine Aufgabe: Telegram kann einen Knopf zum Abhaken anbieten.
objektId: aufgabe.id,
}))
}
@@ -266,7 +278,15 @@ async function zeitblockErinnerungenBauen(
}))
}
/** Kalender, die mehrfach hintereinander nicht abgleichbar waren. */
/** Lesbare Namen für die Meldung; die Aufzählung selbst ist technisch. */
const INTEGRATION_NAME: Partial<Record<Quelle, string>> = {
ZAMMAD: 'Zammad',
WARTUNGSPLANER: 'Wartungsplaner',
TELEGRAM: 'Telegram',
SPIEGEL: 'Spiegel',
}
/** Kalender und Integrationen, die mehrfach hintereinander klemmten. */
async function abgleichfehlerBauen(
benutzerId: string,
zeitpunkt: Date,
@@ -283,14 +303,34 @@ async function abgleichfehlerBauen(
select: { id: true, anzeigename: true, letzterFehler: true },
})
// Eine klemmende Integration wiegt genauso schwer wie ein klemmender
// Kalender: In beiden Fällen steht die Liste auf einem alten Stand, ohne
// dass es jemandem auffiele.
const integrationen = await db.integrationszustand.findMany({
where: {
letzterFehler: { not: null },
fehlversuche: { gte: INTEGRATION_FEHLER_AB_VERSUCH },
},
select: { quelle: true, letzterFehler: true },
})
const tag = alsTagesschluessel(zeitpunkt)
return konten.map((konto) => ({
return [
...konten.map((konto) => ({
anlass: 'abgleichfehler' as const,
schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag),
titel: 'Ein Kalender lässt sich nicht abgleichen',
text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`,
pfad: '/kalenderkonten',
}))
})),
...integrationen.map((i) => ({
anlass: 'abgleichfehler' as const,
schluessel: meldungsSchluessel('abgleichfehler', i.quelle, tag),
titel: 'Eine Integration klemmt',
text: `${INTEGRATION_NAME[i.quelle] ?? i.quelle}: ${i.letzterFehler ?? 'unbekannter Fehler'}`,
pfad: '/integrationen',
})),
]
}
export type VersandBericht = { geprueft: number; gesendet: number }
+197
View File
@@ -0,0 +1,197 @@
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
}
+213
View File
@@ -0,0 +1,213 @@
import 'server-only'
import { createHmac, timingSafeEqual } from 'node:crypto'
import {
sendungFrisch,
spiegelSendung,
type Fund,
type SpiegelSendung,
} from '@/lib/integration'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
import {
fundeAufnehmen,
laufGescheitert,
laufGeglueckt,
type Anstoss,
} from '@/server/integration/aufnahme'
/**
* Der Spiegel: `firma` sendet, `daheim` empfängt.
*
* Zwei Dinge gehen hinüber. **Belegungen** sagen nur, wann Zeit weg ist —
* ohne Titel, ohne Aufgabe, ohne Kunde. So kollidiert die private Planung
* nicht mit der dienstlichen, ohne dass Firmeninhalte das Haus verlassen.
* **Aufgaben** gehen nur mit, wenn sie ausdrücklich dafür markiert sind; das
* ist eine bewusste Einzelentscheidung, kein Schalter für alles.
*
* Die Richtung ist fest verdrahtet: Was in der Firma passiert, darf daheim
* sichtbar sein, umgekehrt nicht. Das ist keine Technik, sondern eine
* Hausregel — und deshalb steckt sie in den Instanzfähigkeiten, nicht in
* einer Einstellung, die sich verstellen ließe.
*/
/** Wie weit voraus gespiegelt wird. Weiter zurück interessiert niemanden. */
const FENSTER_TAGE_VORAUS = 30
export function signieren(koerper: string, geheimnis: string): string {
return createHmac('sha256', geheimnis).update(koerper).digest('hex')
}
/**
* Prüft die Signatur in gleichbleibender Zeit.
*
* Ein einfacher Vergleich verrät über die Laufzeit, wie viele Zeichen schon
* stimmen — damit ließe sich die Signatur Stück für Stück erraten.
*/
export function signaturStimmt(
koerper: string,
signatur: string,
geheimnis: string,
): boolean {
const erwartet = Buffer.from(signieren(koerper, geheimnis), 'utf8')
const bekommen = Buffer.from(signatur, 'utf8')
if (erwartet.length !== bekommen.length) return false
return timingSafeEqual(erwartet, bekommen)
}
// --- Senden (firma) --------------------------------------------------------
/** Stellt zusammen, was hinübergeht. */
export async function sendungBauen(zeitpunkt: Date): Promise<SpiegelSendung> {
const bis = new Date(zeitpunkt.getTime() + FENSTER_TAGE_VORAUS * 86_400_000)
const [zeitbloecke, aufgaben] = await Promise.all([
db.zeitblock.findMany({
where: { beginn: { gte: zeitpunkt, lte: bis } },
orderBy: { beginn: 'asc' },
take: 500,
// Kein Titel, keine Aufgabe: Nur wann die Zeit weg ist.
select: { id: true, beginn: true, ende: true },
}),
db.aufgabe.findMany({
where: { spiegeln: true },
orderBy: { erstelltAm: 'asc' },
take: 500,
select: { id: true, titel: true, faelligAm: true, status: true },
}),
])
return {
gesendetAm: zeitpunkt.toISOString(),
belegungen: zeitbloecke.map((z) => ({
ref: z.id,
beginn: z.beginn.toISOString(),
ende: z.ende.toISOString(),
})),
aufgaben: aufgaben.map((a) => ({
ref: a.id,
titel: a.titel,
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
erledigt: a.status === 'ERLEDIGT' || a.status === 'VERWORFEN',
})),
}
}
/** Schickt eine Runde an die andere Instanz. */
export async function spiegelSenden(): Promise<{ belegungen: number; aufgaben: number }> {
const einst = umgebung()
try {
const sendung = await sendungBauen(jetzt())
const koerper = JSON.stringify(sendung)
const gelesen = await fetch(`${einst.SPIEGEL_ZIEL.replace(/\/+$/, '')}/api/spiegel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Planer-Signatur': signieren(koerper, einst.SPIEGEL_GEHEIMNIS),
},
body: koerper,
signal: AbortSignal.timeout(20_000),
})
if (!gelesen.ok) {
throw new Error(`Der Spiegel antwortete mit HTTP ${gelesen.status}.`)
}
await laufGeglueckt('SPIEGEL')
return { belegungen: sendung.belegungen.length, aufgaben: sendung.aufgaben.length }
} catch (fehler) {
await laufGescheitert('SPIEGEL', fehler)
throw fehler
}
}
// --- Empfangen (daheim) ----------------------------------------------------
export type Empfangsbericht = {
belegungen: number
entfernt: number
aufgaben: number
}
/**
* Nimmt eine geprüfte Sendung auf.
*
* Belegungen werden abgeglichen, nicht angehäuft: Was der Sender nicht mehr
* schickt, ist drüben ausgeplant worden und verschwindet auch hier. Sonst
* stünde daheim für immer eine Sperre, die es längst nicht mehr gibt.
*/
export async function sendungAufnehmen(
sendung: SpiegelSendung,
anstoss: Anstoss,
): Promise<Empfangsbericht> {
const gesehen = new Set(sendung.belegungen.map((b) => b.ref))
for (const belegung of sendung.belegungen) {
const werte = {
beginn: new Date(belegung.beginn),
ende: new Date(belegung.ende),
empfangenAm: jetzt(),
}
await db.spiegelbelegung.upsert({
where: { quelleRef: belegung.ref },
update: werte,
create: { quelleRef: belegung.ref, ...werte },
})
}
const veraltet = await db.spiegelbelegung.findMany({
where: { quelleRef: { notIn: [...gesehen] } },
select: { id: true },
})
if (veraltet.length > 0) {
await db.spiegelbelegung.deleteMany({
where: { id: { in: veraltet.map((v) => v.id) } },
})
}
const funde: Fund[] = sendung.aufgaben.map((a) => ({
ref: a.ref,
titel: a.titel,
faelligAm: a.faelligAm ? new Date(a.faelligAm) : null,
erledigt: a.erledigt,
}))
const bericht = await fundeAufnehmen('SPIEGEL', funde, anstoss)
await laufGeglueckt('SPIEGEL')
return {
belegungen: sendung.belegungen.length,
entfernt: veraltet.length,
aufgaben: bericht.angelegt + bericht.aktualisiert + bericht.geschlossen,
}
}
/**
* Prüft Signatur und Frische und gibt die Sendung frei.
*
* Gibt bei jedem Fehlschlag dieselbe knappe Auskunft: Wer von außen probiert,
* soll nicht erfahren, ob das Geheimnis stimmte und nur die Uhr falsch ging.
*/
export function sendungPruefen(
koerper: string,
signatur: string | null,
zeitpunkt: Date,
): { ok: true; sendung: SpiegelSendung } | { ok: false } {
const einst = umgebung()
if (!einst.spiegelEmpfangenAktiv || !signatur) return { ok: false }
if (!signaturStimmt(koerper, signatur, einst.SPIEGEL_GEHEIMNIS)) return { ok: false }
let inhalt: unknown
try {
inhalt = JSON.parse(koerper)
} catch {
return { ok: false }
}
const gelesen = spiegelSendung.safeParse(inhalt)
if (!gelesen.success) return { ok: false }
if (!sendungFrisch(new Date(gelesen.data.gesendetAm), zeitpunkt)) return { ok: false }
return { ok: true, sendung: gelesen.data }
}
+392
View File
@@ -0,0 +1,392 @@
import 'server-only'
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import { alsTitel, type Fund } from '@/lib/integration'
import type { Meldung } from '@/lib/benachrichtigung'
import { auditSchreiben } from '@/server/audit'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
import {
fundeAufnehmen,
laufGescheitert,
laufGeglueckt,
merkerLesen,
type Anstoss,
} from '@/server/integration/aufnahme'
/**
* Telegram – nur in der Instanz „daheim".
*
* Drei Dinge in einem Kanal: erfassen (Nachricht wird Aufgabe), melden
* (dritter Weg neben Push und Mail) und abhaken (Knopf unter der Meldung).
*
* Geholt wird per `getUpdates`, nicht per Webhook. Ein Webhook verlangt eine
* von außen erreichbare Adresse mit gültigem Zertifikat; der Planer soll auch
* hinter einem Anschluss ohne feste Adresse laufen.
*
* **Wer nicht im Adressbuch steht, wird verworfen.** Ein Bot-Token lässt sich
* abgreifen, und wer die Kennung des Bots kennt, kann ihm schreiben. Die
* erlaubten Chats stehen in der Datenbank; alles andere wird stillschweigend
* weggeworfen — eine Antwort wäre schon die Auskunft, dass es den Bot gibt.
*/
const HOECHSTENS = 50
const nachricht = z.object({
message_id: z.number().int(),
date: z.number().int(),
chat: z.object({ id: z.number().int() }),
text: z.string().optional(),
})
const rueckfrage = z.object({
id: z.string(),
data: z.string().optional(),
message: z
.object({ message_id: z.number().int(), chat: z.object({ id: z.number().int() }) })
.optional(),
})
const aktualisierung = z.object({
update_id: z.number().int(),
message: nachricht.optional(),
callback_query: rueckfrage.optional(),
})
const antwort = z.object({
ok: z.boolean(),
result: z.array(z.unknown()).optional(),
description: z.string().optional(),
})
async function rufen(
methode: string,
koerper: Record<string, unknown>,
): Promise<unknown> {
const einst = umgebung()
const gelesen = await fetch(
`https://api.telegram.org/bot${einst.TELEGRAM_TOKEN}/${methode}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(koerper),
signal: AbortSignal.timeout(70_000),
},
)
if (!gelesen.ok) {
// Der Körper enthält im Fehlerfall den Token nicht, wohl aber die
// Nachricht – deshalb nur der Status.
throw new Error(`Telegram antwortete mit HTTP ${gelesen.status}.`)
}
const inhalt: unknown = await gelesen.json()
const geprueft = antwort.safeParse(inhalt)
if (!geprueft.success || !geprueft.data.ok) {
throw new Error('Telegram lehnte die Anfrage ab.')
}
return geprueft.data.result
}
// --- Senden ----------------------------------------------------------------
export type Knopf = { text: string; daten: string }
/** Schickt eine Nachricht in einen Chat. Gibt zurück, ob es geklappt hat. */
export async function telegramSenden(
chatId: bigint,
text: string,
knoepfe: readonly Knopf[] = [],
): Promise<boolean> {
try {
await rufen('sendMessage', {
chat_id: Number(chatId),
text,
...(knoepfe.length > 0
? {
reply_markup: {
inline_keyboard: [
knoepfe.map((k) => ({ text: k.text, callback_data: k.daten })),
],
},
}
: {}),
})
return true
} catch (fehler) {
console.error(
'Telegram-Versand fehlgeschlagen:',
fehler instanceof Error ? fehler.message : 'unbekannter Fehler',
)
return false
}
}
/**
* Schickt eine Meldung an alle Chats eines Benutzers, die Meldungen wollen.
*
* Hängt eine Aufgabe daran, kommt ein Knopf mit: abhaken, ohne die Anwendung
* zu öffnen. Genau dafür lohnt sich der Umweg über Telegram.
*/
export async function telegramMeldung(
benutzerId: string,
meldung: Meldung,
): Promise<number> {
const chats = await db.telegramChat.findMany({
where: { benutzerId, meldungen: true },
select: { id: true, chatId: true },
})
const einst = umgebung()
const text = `${meldung.titel}\n\n${meldung.text}\n\n${einst.APP_URL}${meldung.pfad}`
const knoepfe: Knopf[] = meldung.objektId
? [{ text: '✓ Erledigt', daten: `erledigt:${meldung.objektId}` }]
: []
let zugestellt = 0
for (const chat of chats) {
if (await telegramSenden(chat.chatId, text, knoepfe)) {
zugestellt++
await db.telegramChat.update({
where: { id: chat.id },
data: { zuletztBenutzt: jetzt() },
})
}
}
return zugestellt
}
// --- Kopplung --------------------------------------------------------------
/**
* Wie lange ein Kopplungscode gilt.
*
* Kurz genug, dass ein abgeschriebener Code nichts mehr nützt; lang genug,
* um in Ruhe das Handy zu suchen.
*/
export const KOPPLUNG_MINUTEN = 10
/** Ziffern und Großbuchstaben ohne die, die sich verwechseln lassen. */
const ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
/**
* Der Code, allein oder hinter `/start`.
*
* Telegram schickt beim ersten Öffnen eines Bots `/start` mit; wer den Code
* abtippt, schickt ihn nackt. Beides muss gehen.
*/
const KOPPLUNGSBEFEHL = /^(?:\/start\s+)?([A-Za-z0-9]{8})$/
export function kopplungscodeBauen(): string {
const bytes = randomBytes(8)
return [...bytes]
.map((b) => ZEICHEN[b % ZEICHEN.length])
.join('')
.slice(0, 8)
}
/**
* Löst einen Code ein und trägt den Chat ein.
*
* Der Code gilt genau einmal: Er verschwindet mit dem ersten Einlösen, auch
* wenn dabei etwas schiefgeht.
*/
async function kopplungEinloesen(
code: string,
chatId: number,
): Promise<'gekoppelt' | 'schon-da' | 'unbekannt'> {
const kopplung = await db.telegramKopplung.findUnique({
where: { code },
select: { id: true, benutzerId: true, gueltigBis: true },
})
if (!kopplung) return 'unbekannt'
await db.telegramKopplung.delete({ where: { id: kopplung.id } })
if (kopplung.gueltigBis.getTime() < jetzt().getTime()) return 'unbekannt'
const vorhanden = await db.telegramChat.findUnique({
where: { chatId: BigInt(chatId) },
select: { id: true },
})
if (vorhanden) return 'schon-da'
const chat = await db.telegramChat.create({
data: { chatId: BigInt(chatId), benutzerId: kopplung.benutzerId },
select: { id: true },
})
await auditSchreiben({
akteur: { id: kopplung.benutzerId, email: 'telegram@intern' },
aktion: 'telegram.gekoppelt',
objektTyp: 'TelegramChat',
objektId: chat.id,
// Die Chatkennung gehört zu einer Person und bleibt draußen.
})
return 'gekoppelt'
}
/** Entfernt abgelaufene Codes. */
export async function alteKopplungenEntfernen(zeitpunkt: Date): Promise<number> {
const weg = await db.telegramKopplung.deleteMany({
where: { gueltigBis: { lt: zeitpunkt } },
})
return weg.count
}
// --- Holen -----------------------------------------------------------------
/** Ein bekannter Chat, oder null. Unbekannte werden nicht beantwortet. */
async function chatPruefen(chatId: number) {
return db.telegramChat.findUnique({
where: { chatId: BigInt(chatId) },
select: { id: true, benutzerId: true },
})
}
/**
* Hakt eine Aufgabe über den Knopf unter einer Meldung ab.
*
* Nur abhaken, nichts anderes: Ein Knopf, der löscht oder verschiebt, wäre
* aus einem Chat heraus zu leicht versehentlich getroffen.
*/
async function erledigtMelden(aufgabeId: string, anstoss: Anstoss): Promise<string> {
const aufgabe = await db.aufgabe.findUnique({
where: { id: aufgabeId },
select: { id: true, titel: true, status: true },
})
if (!aufgabe) return 'Diese Aufgabe gibt es nicht mehr.'
if (aufgabe.status === 'ERLEDIGT') return 'War schon erledigt.'
await db.aufgabe.update({
where: { id: aufgabe.id },
data: { status: 'ERLEDIGT', erledigtAm: jetzt() },
})
await auditSchreiben({
akteur: anstoss,
aktion: 'aufgabe.erledigt',
objektTyp: 'Aufgabe',
objektId: aufgabe.id,
details: { ueber: 'telegram' },
})
return 'Erledigt.'
}
/**
* Holt alles Neue ab und arbeitet es ab.
*
* Der Merker ist die Kennung der zuletzt geholten Aktualisierung. Telegram
* löscht alles darunter, sobald wir mit dem nächsten Offset fragen — das ist
* zugleich die Bestätigung, dass wir es haben.
*/
export async function telegramAbholen(anstoss: Anstoss): Promise<{
aufgaben: number
abgehakt: number
gekoppelt: number
verworfen: number
}> {
try {
const merker = await merkerLesen('TELEGRAM')
const offset = merker ? Number(merker) : undefined
const roh = await rufen('getUpdates', {
...(offset === undefined || Number.isNaN(offset) ? {} : { offset }),
limit: HOECHSTENS,
// Kein Long-Polling: Der Job läuft ohnehin im Takt und soll nicht
// minutenlang eine Verbindung offen halten.
timeout: 0,
allowed_updates: ['message', 'callback_query'],
})
const eintraege = z.array(aktualisierung).safeParse(roh)
if (!eintraege.success) throw new Error('Telegram antwortete in unbekannter Form.')
const funde: Fund[] = []
let abgehakt = 0
let gekoppelt = 0
let verworfen = 0
let hoechste = offset ? offset - 1 : 0
for (const eintrag of eintraege.data) {
hoechste = Math.max(hoechste, eintrag.update_id)
if (eintrag.callback_query) {
const chatId = eintrag.callback_query.message?.chat.id
const bekannt = chatId === undefined ? null : await chatPruefen(chatId)
if (!bekannt) {
verworfen++
continue
}
const daten = eintrag.callback_query.data ?? ''
const [was, id] = daten.split(':')
const meldung =
was === 'erledigt' && id
? await erledigtMelden(id, {
id: bekannt.benutzerId,
email: anstoss.email,
})
: 'Unbekannter Knopf.'
abgehakt += meldung === 'Erledigt.' ? 1 : 0
await rufen('answerCallbackQuery', {
callback_query_id: eintrag.callback_query.id,
text: meldung,
}).catch(() => undefined)
continue
}
const nachricht = eintrag.message
if (!nachricht?.text) continue
const bekannt = await chatPruefen(nachricht.chat.id)
if (!bekannt) {
// Die einzige Ausnahme: ein gültiger Kopplungscode. Er ist der
// Beweis, dass jemand mit Zugang zur Anwendung diesen Chat will.
const code = KOPPLUNGSBEFEHL.exec(nachricht.text.trim())?.[1]?.toUpperCase()
if (code) {
const ergebnis = await kopplungEinloesen(code, nachricht.chat.id)
if (ergebnis !== 'unbekannt') {
gekoppelt++
await telegramSenden(
BigInt(nachricht.chat.id),
ergebnis === 'gekoppelt'
? 'Verbunden. Schick mir eine Nachricht, und daraus wird eine Aufgabe.'
: 'Dieser Chat war schon verbunden.',
)
continue
}
}
// Sonst kein Wort zurück. Wer nicht eingetragen ist, soll nicht
// erfahren, dass hier etwas läuft.
verworfen++
continue
}
const { titel, rest } = alsTitel(nachricht.text)
if (titel.length === 0) continue
funde.push({
ref: `${nachricht.chat.id}:${nachricht.message_id}`,
titel,
notiz: rest,
})
await db.telegramChat.update({
where: { id: bekannt.id },
data: { zuletztBenutzt: jetzt() },
})
}
const bericht = await fundeAufnehmen('TELEGRAM', funde, anstoss)
// Erst bestätigen, wenn alles verarbeitet ist: Ein Offset, der zu früh
// hochgezählt wird, verliert Nachrichten unwiederbringlich.
await laufGeglueckt('TELEGRAM', String(hoechste + 1))
return { aufgaben: bericht.angelegt, abgehakt, gekoppelt, verworfen }
} catch (fehler) {
await laufGescheitert('TELEGRAM', fehler)
throw fehler
}
}
+143
View File
@@ -0,0 +1,143 @@
import 'server-only'
import { z } from 'zod'
import { alsTitel, type Fund } from '@/lib/integration'
import { umgebung } from '@/server/umgebung'
import {
fundeAufnehmen,
laufGescheitert,
laufGeglueckt,
type Anstoss,
type Aufnahmebericht,
} from '@/server/integration/aufnahme'
/**
* Zammad – nur in der Instanz „firma".
*
* Gelesen wird über die Suche mit einem Ausdruck aus der Konfiguration; was
* darauf passt, wird zur Aufgabe. Der Token gehört einem Benutzer und sieht
* genau das, was dieser Benutzer sehen darf — der Planer umgeht keine Rechte.
*
* Geschrieben wird nach Zammad **nicht**. Ein Ticket schließt man im
* Ticketsystem, sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
*/
/** Wie viele Tickets höchstens je Lauf. Mehr wäre ohnehin keine Planung. */
const HOECHSTENS = 100
/**
* Zammads Zuständen ist nicht anzusehen, ob sie „fertig" bedeuten. Der
* `state_type` sagt es; die Suche liefert ihn nicht immer mit, deshalb
* zusätzlich die üblichen Namen.
*/
const ERLEDIGT_ZUSTAENDE = ['closed', 'merged', 'removed']
const ticket = z.object({
id: z.number().int(),
number: z.string(),
title: z.string(),
state: z.string().optional(),
/** 1 low, 2 normal, 3 high – Zammads Voreinstellung. */
priority: z.string().optional(),
pending_time: z.string().nullable().optional(),
})
type Ticket = z.infer<typeof ticket>
const antwort = z.object({
assets: z.object({ Ticket: z.record(z.string(), z.unknown()).optional() }).optional(),
tickets: z.array(z.number().int()).optional(),
})
/**
* Übersetzt Zammads Priorität in unsere Skala.
*
* Zammad: 1 niedrig, 2 normal, 3 hoch. Hier: 1 dringend … 5 irgendwann.
* Die Zahlen laufen also gegenläufig, was beim Lesen gern übersehen wird.
*/
function prioritaetVon(zammad: string | undefined): number | null {
if (!zammad) return null
if (zammad.startsWith('3')) return 2
if (zammad.startsWith('1')) return 4
return 3
}
function alsFund(t: Ticket, basis: string): Fund {
const { titel, rest } = alsTitel(`#${t.number} ${t.title}`)
const zustand = (t.state ?? '').toLowerCase()
return {
ref: String(t.id),
titel,
notiz: rest,
url: `${basis}/#ticket/zoom/${t.id}`,
faelligAm: t.pending_time ? new Date(t.pending_time) : null,
prioritaet: prioritaetVon(t.priority),
erledigt: ERLEDIGT_ZUSTAENDE.some((z) => zustand.includes(z)),
}
}
/** Holt die Tickets, auf die der Suchausdruck passt. */
export async function ticketsHolen(): Promise<Fund[]> {
const einst = umgebung()
const basis = einst.ZAMMAD_URL.replace(/\/+$/, '')
const adresse = new URL(`${basis}/api/v1/tickets/search`)
adresse.searchParams.set('query', einst.ZAMMAD_ABFRAGE)
adresse.searchParams.set('limit', String(HOECHSTENS))
// Ohne "full" liefert die Suche nur Kennungen und wir müssten jedes Ticket
// einzeln nachladen.
adresse.searchParams.set('expand', 'true')
const gelesen = await fetch(adresse, {
headers: {
Authorization: `Token token=${einst.ZAMMAD_TOKEN}`,
Accept: 'application/json',
},
// Ein hängendes Ticketsystem darf den Job nicht festhalten.
signal: AbortSignal.timeout(20_000),
})
if (!gelesen.ok) {
// Nie den Antwortkörper: Zammad schickt im Fehlerfall gern den ganzen
// Datensatz mit.
throw new Error(`Zammad antwortete mit HTTP ${gelesen.status}.`)
}
const inhalt: unknown = await gelesen.json()
// Mit expand=true ist die Antwort eine Liste, ohne ein Objekt mit assets.
if (Array.isArray(inhalt)) {
return inhalt
.map((eintrag) => ticket.safeParse(eintrag))
.filter((g) => g.success)
.map((g) => alsFund(g.data, basis))
}
const huelle = antwort.safeParse(inhalt)
if (!huelle.success) throw new Error('Zammad antwortete in unbekannter Form.')
const werte = Object.values(huelle.data.assets?.Ticket ?? {})
return werte
.map((eintrag) => ticket.safeParse(eintrag))
.filter((g) => g.success)
.map((g) => alsFund(g.data, basis))
}
/**
* Ein Durchlauf: holen, aufnehmen, Zustand fortschreiben.
*
* Scheitert das Holen, bleibt der bisherige Stand stehen. Eine leere Antwort
* darf niemals als „alles erledigt" durchgehen — deshalb schließt diese
* Integration nur, was Zammad ausdrücklich als geschlossen liefert.
*/
export async function zammadAbgleichen(anstoss: Anstoss): Promise<Aufnahmebericht> {
try {
const funde = await ticketsHolen()
const bericht = await fundeAufnehmen('ZAMMAD', funde, anstoss)
await laufGeglueckt('ZAMMAD')
return bericht
} catch (fehler) {
await laufGescheitert('ZAMMAD', fehler)
throw fehler
}
}
+64
View File
@@ -7,6 +7,10 @@ import {
alteNachweiseEntfernen,
benachrichtigungenVerschicken,
} from '@/server/benachrichtigung/versand'
import { zammadAbgleichen } from '@/server/integration/zammad'
import { alteKopplungenEntfernen, telegramAbholen } from '@/server/integration/telegram'
import { spiegelSenden } from '@/server/integration/spiegel'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
/**
@@ -31,6 +35,24 @@ const AUFRAEUM_PLAN = '17 3 * * *'
* dank Wiederholungssperre nur einmal gemeldet.
*/
const MELDUNGS_PLAN = '*/10 * * * *'
/**
* Integrationen laufen im eigenen Takt.
*
* Telegram oefter als der Rest: Wer dem Bot etwas schickt, erwartet die
* Aufgabe gleich in der Liste, nicht in einer Viertelstunde. Zammad und der
* Spiegel duerfen traeger sein — dort aendert sich nichts im Minutentakt.
*/
const TELEGRAM_PLAN = '*/2 * * * *'
const ZAMMAD_PLAN = '*/15 * * * *'
const SPIEGEL_PLAN = '*/15 * * * *'
/**
* Wer im Protokoll steht, wenn niemand geklickt hat.
*
* Ein Job hat keinen Benutzer. Statt den Eintrag wegzulassen, traegt er
* sichtbar den Dienst ein — sonst sieht es aus, als sei nichts passiert.
*/
const DIENST = { id: null, email: 'dienst@intern' }
async function alleKontenAbgleichen(): Promise<void> {
const zeitpunkt = jetzt()
@@ -87,11 +109,53 @@ export function jobsStarten(): void {
})
})
const einst = umgebung()
if (einst.zammadAktiv) {
cron.schedule(ZAMMAD_PLAN, () => {
void zammadAbgleichen(DIENST)
.then((bericht) => {
if (bericht.angelegt > 0 || bericht.geschlossen > 0) {
console.warn(
`Zammad: ${bericht.angelegt} neu, ${bericht.geschlossen} geschlossen.`,
)
}
})
.catch((fehler: unknown) => {
console.error('Zammad-Abgleich abgebrochen:', fehler)
})
})
}
if (einst.telegramAktiv) {
cron.schedule(TELEGRAM_PLAN, () => {
void telegramAbholen(DIENST)
.then((bericht) => {
if (bericht.verworfen > 0) {
// Nie die Chatkennung: Sie gehoert zu einer Person.
console.warn(`Telegram: ${bericht.verworfen} Nachricht(en) verworfen.`)
}
})
.catch((fehler: unknown) => {
console.error('Telegram-Abholen abgebrochen:', fehler)
})
})
}
if (einst.spiegelSendenAktiv) {
cron.schedule(SPIEGEL_PLAN, () => {
void spiegelSenden().catch((fehler: unknown) => {
console.error('Spiegel-Versand abgebrochen:', fehler)
})
})
}
cron.schedule(AUFRAEUM_PLAN, () => {
void Promise.all([
alteTermineEntfernen(),
abgelaufeneChallengesEntfernen(jetzt()),
alteNachweiseEntfernen(jetzt()),
alteKopplungenEntfernen(jetzt()),
])
.then(() => undefined)
.catch((fehler: unknown) => {
+39 -1
View File
@@ -1,6 +1,6 @@
import 'server-only'
import { z } from 'zod'
import { INSTANZEN, type Instanz } from '@/lib/instanz'
import { INSTANZEN, istErlaubt, type Instanz } from '@/lib/instanz'
/**
* Jede Einstellung von aussen wird geprueft, bevor sie benutzt wird. Fehlt
@@ -57,6 +57,26 @@ const schema = z.object({
/// Implizites TLS (Port 465). Sonst wird STARTTLS versucht.
SMTP_TLS: jaNein.default(false),
// --- Integrationen (M6) ---
// Zammad, nur in der Instanz "firma". Der Token gehoert einem Benutzer und
// sieht genau das, was dieser Benutzer sehen darf.
ZAMMAD_URL: z.string().trim().default(''),
ZAMMAD_TOKEN: z.string().trim().default(''),
/// Suchausdruck in Zammads Syntax. Was hier passt, wird zur Aufgabe.
ZAMMAD_ABFRAGE: z
.string()
.trim()
.default('owner_id:me AND (state.name:new OR state.name:open)'),
// Telegram, nur in der Instanz "daheim".
TELEGRAM_TOKEN: z.string().trim().default(''),
// Spiegel: firma sendet, daheim empfaengt. Dasselbe Geheimnis auf beiden
// Seiten; ohne gueltige Signatur nimmt der Empfaenger nichts an.
SPIEGEL_GEHEIMNIS: z.string().trim().default(''),
/// Nur auf der sendenden Seite: wohin geschickt wird.
SPIEGEL_ZIEL: z.string().trim().default(''),
DEMO_MODUS: jaNein.default(false),
})
@@ -70,6 +90,12 @@ export type Umgebung = z.infer<typeof schema> & {
/** Web-Push ist erst mit vollstaendigem VAPID-Satz moeglich. */
pushAktiv: boolean
mailAktiv: boolean
/** Zammad braucht Adresse und Token – und die Instanz muss es erlauben. */
zammadAktiv: boolean
telegramAktiv: boolean
/** Senden geht nur mit Ziel, Empfangen schon mit dem Geheimnis allein. */
spiegelSendenAktiv: boolean
spiegelEmpfangenAktiv: boolean
}
let zwischengespeichert: Umgebung | null = null
@@ -95,6 +121,18 @@ function lesen(): Umgebung {
werte.VAPID_OEFFENTLICH && werte.VAPID_PRIVAT && werte.VAPID_KONTAKT,
),
mailAktiv: Boolean(werte.SMTP_HOST && werte.SMTP_ABSENDER),
// Die Instanz entscheidet mit: eine Integration, die hier nicht erlaubt
// ist, laesst sich auch mit vollstaendiger Konfiguration nicht anschalten.
zammadAktiv:
istErlaubt(werte.INSTANZ, 'integration:zammad') &&
Boolean(werte.ZAMMAD_URL && werte.ZAMMAD_TOKEN),
telegramAktiv:
istErlaubt(werte.INSTANZ, 'integration:telegram') && Boolean(werte.TELEGRAM_TOKEN),
spiegelSendenAktiv:
istErlaubt(werte.INSTANZ, 'spiegel:senden') &&
Boolean(werte.SPIEGEL_GEHEIMNIS && werte.SPIEGEL_ZIEL),
spiegelEmpfangenAktiv:
istErlaubt(werte.INSTANZ, 'spiegel:empfangen') && Boolean(werte.SPIEGEL_GEHEIMNIS),
}
}