From 7f8f50512611d0a35a1840f969b57e0dcf74a13c Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:03:32 +0200
Subject: [PATCH 01/14] feat: Datenmodell und Umgebung fuer Integrationen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.env.example | 23 +++++
.../migration.sql | 62 ++++++++++++
.../migration.sql | 16 ++++
prisma/schema.prisma | 96 ++++++++++++++++++-
src/server/umgebung.ts | 40 +++++++-
5 files changed, 233 insertions(+), 4 deletions(-)
create mode 100644 prisma/migrations/20260911185221_integrationen/migration.sql
create mode 100644 prisma/migrations/20260911204204_telegram_kopplung/migration.sql
diff --git a/.env.example b/.env.example
index 787a36e..614d955 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/prisma/migrations/20260911185221_integrationen/migration.sql b/prisma/migrations/20260911185221_integrationen/migration.sql
new file mode 100644
index 0000000..7a0d340
--- /dev/null
+++ b/prisma/migrations/20260911185221_integrationen/migration.sql
@@ -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;
diff --git a/prisma/migrations/20260911204204_telegram_kopplung/migration.sql b/prisma/migrations/20260911204204_telegram_kopplung/migration.sql
new file mode 100644
index 0000000..d0945b7
--- /dev/null
+++ b/prisma/migrations/20260911204204_telegram_kopplung/migration.sql
@@ -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");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index dd7b924..772bf88 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
@@ -391,9 +396,10 @@ model Benachrichtigungseinstellung {
benutzerId String @unique
/// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts.
- anlaesse String @default("tagesueberblick,faelligkeit")
- perPush Boolean @default(false)
- perMail Boolean @default(false)
+ 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")
+}
diff --git a/src/server/umgebung.ts b/src/server/umgebung.ts
index 832ae18..1c3f953 100644
--- a/src/server/umgebung.ts
+++ b/src/server/umgebung.ts
@@ -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 & {
/** 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),
}
}
From e4d3547457e4ca835243a1e86b526168abed4a2a Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:03:33 +0200
Subject: [PATCH 02/14] feat: Aufnahmeregeln fuer Integrationen als reine Logik
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/lib/integration.test.ts | 188 ++++++++++++++++++++++++++++++++++++
src/lib/integration.ts | 177 +++++++++++++++++++++++++++++++++
2 files changed, 365 insertions(+)
create mode 100644 src/lib/integration.test.ts
create mode 100644 src/lib/integration.ts
diff --git a/src/lib/integration.test.ts b/src/lib/integration.test.ts
new file mode 100644
index 0000000..0242415
--- /dev/null
+++ b/src/lib/integration.test.ts
@@ -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 {
+ return { ref: 'T-1', titel: 'Drucker klemmt', ...teile }
+}
+
+function bestand(teile: Partial = {}): 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)
+ })
+})
diff --git a/src/lib/integration.ts b/src/lib/integration.ts
new file mode 100644
index 0000000..858b183
--- /dev/null
+++ b/src/lib/integration.ts
@@ -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 }
+ | { 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 = {}
+ 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
+
+/**
+ * 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
+}
From 6dad3331e28b71ff4c6b9ae48294f4f3646aeec7 Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:04:03 +0200
Subject: [PATCH 03/14] feat: Zammad, Telegram und den Spiegel anbinden
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/app/api/spiegel/route.ts | 45 ++++
src/server/integration/aufnahme.ts | 197 +++++++++++++++
src/server/integration/spiegel.ts | 213 ++++++++++++++++
src/server/integration/telegram.ts | 392 +++++++++++++++++++++++++++++
src/server/integration/zammad.ts | 143 +++++++++++
5 files changed, 990 insertions(+)
create mode 100644 src/app/api/spiegel/route.ts
create mode 100644 src/server/integration/aufnahme.ts
create mode 100644 src/server/integration/spiegel.ts
create mode 100644 src/server/integration/telegram.ts
create mode 100644 src/server/integration/zammad.ts
diff --git a/src/app/api/spiegel/route.ts b/src/app/api/spiegel/route.ts
new file mode 100644
index 0000000..cbfeea9
--- /dev/null
+++ b/src/app/api/spiegel/route.ts
@@ -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 })
+}
diff --git a/src/server/integration/aufnahme.ts b/src/server/integration/aufnahme.ts
new file mode 100644
index 0000000..f94dc27
--- /dev/null
+++ b/src/server/integration/aufnahme.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
+}
diff --git a/src/server/integration/spiegel.ts b/src/server/integration/spiegel.ts
new file mode 100644
index 0000000..fb5819b
--- /dev/null
+++ b/src/server/integration/spiegel.ts
@@ -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 {
+ 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 {
+ 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 }
+}
diff --git a/src/server/integration/telegram.ts b/src/server/integration/telegram.ts
new file mode 100644
index 0000000..68ea363
--- /dev/null
+++ b/src/server/integration/telegram.ts
@@ -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,
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
+ }
+}
diff --git a/src/server/integration/zammad.ts b/src/server/integration/zammad.ts
new file mode 100644
index 0000000..5a34395
--- /dev/null
+++ b/src/server/integration/zammad.ts
@@ -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
+
+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 {
+ 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 {
+ 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
+ }
+}
From bc60e5f864209e1f729773688ac0a0f743685c9e Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:04:05 +0200
Subject: [PATCH 04/14] feat: Integrationen takten und Telegram als dritten
Meldeweg
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/lib/benachrichtigung.ts | 12 ++++-
src/server/benachrichtigung/versand.ts | 56 ++++++++++++++++++----
src/server/jobs.ts | 64 ++++++++++++++++++++++++++
3 files changed, 123 insertions(+), 9 deletions(-)
diff --git a/src/lib/benachrichtigung.ts b/src/lib/benachrichtigung.ts
index 0fbb16b..ab756b5 100644
--- a/src/lib/benachrichtigung.ts
+++ b/src/lib/benachrichtigung.ts
@@ -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,
diff --git a/src/server/benachrichtigung/versand.ts b/src/server/benachrichtigung/versand.ts
index c3be9ee..dc97ccd 100644
--- a/src/server/benachrichtigung/versand.ts
+++ b/src/server/benachrichtigung/versand.ts
@@ -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 {
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 {
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> = {
+ 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) => ({
- 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',
- }))
+ 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 }
diff --git a/src/server/jobs.ts b/src/server/jobs.ts
index 62a33ec..93196a5 100644
--- a/src/server/jobs.ts
+++ b/src/server/jobs.ts
@@ -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 {
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) => {
From 1281c3ea9662d9eb47a231412b72223edbfe8426 Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:04:33 +0200
Subject: [PATCH 05/14] feat: Integrationen sichtbar und bedienbar machen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Eine Seite zeigt je Integration, ob sie vorgesehen, eingerichtet oder
gestoert ist, wann sie zuletzt geglueckt ist und woran sie klemmt. Der
Wartungsplaner steht sichtbar als offene Luecke darin, nicht als
Leerstelle.
Telegram-Chats werden hier gekoppelt und wieder getrennt, Meldungen je
Chat einzeln geschaltet. Aufgaben bekommen einen Schalter zum Spiegeln —
einzeln und je Aufgabe, denn wer versehentlich einen ganzen Bereich
spiegelt, schickt Kundennamen nach Hause.
Co-Authored-By: Claude Opus 5
---
.../einstellungen/benachrichtigungen.tsx | 35 ++++
src/app/(app)/einstellungen/page.tsx | 7 +-
src/app/(app)/integrationen/page.tsx | 180 +++++++++++++++++
.../(app)/integrationen/telegram-chats.tsx | 139 +++++++++++++
src/components/kopfleiste.tsx | 1 +
src/server/aktionen/aufgaben.ts | 49 +++++
src/server/aktionen/benachrichtigungen.ts | 25 ++-
src/server/aktionen/integrationen.ts | 191 ++++++++++++++++++
8 files changed, 624 insertions(+), 3 deletions(-)
create mode 100644 src/app/(app)/integrationen/page.tsx
create mode 100644 src/app/(app)/integrationen/telegram-chats.tsx
create mode 100644 src/server/aktionen/integrationen.ts
diff --git a/src/app/(app)/einstellungen/benachrichtigungen.tsx b/src/app/(app)/einstellungen/benachrichtigungen.tsx
index a5baa5e..c8eb0aa 100644
--- a/src/app/(app)/einstellungen/benachrichtigungen.tsx
+++ b/src/app/(app)/einstellungen/benachrichtigungen.tsx
@@ -34,6 +34,7 @@ export type EinstellungAnzeige = {
anlaesse: Anlass[]
perPush: boolean
perMail: boolean
+ perTelegram: boolean
ueberblickUm: string
ruheVon: string
ruheBis: string
@@ -46,6 +47,9 @@ type Eigenschaften = {
geraete: GeraetAnzeige[]
pushMoeglich: boolean
mailMoeglich: boolean
+ telegramMoeglich: boolean
+ /** Wie viele Chats Meldungen empfangen. Null heisst: noch keiner. */
+ telegramChats: number
}
/**
@@ -68,6 +72,8 @@ export function Benachrichtigungen({
geraete,
pushMoeglich,
mailMoeglich,
+ telegramMoeglich,
+ telegramChats,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
@@ -311,6 +317,35 @@ export function Benachrichtigungen({
)}
+
+
+
+
+ Telegram
+ {!telegramMoeglich ? (
+
+ {' '}
+ — auf diesem Server nicht eingerichtet
+
+ ) : telegramChats === 0 ? (
+
+ {' '}
+ — noch kein Chat eingetragen
+
+ ) : (
+
+ {' '}
+ — an {telegramChats} Chat{telegramChats === 1 ? '' : 's'}, mit Knopf zum
+ Abhaken
+
+ )}
+
+
diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx
index c1514a4..4562bec 100644
--- a/src/app/(app)/einstellungen/page.tsx
+++ b/src/app/(app)/einstellungen/page.tsx
@@ -23,13 +23,14 @@ export default async function EinstellungenSeite() {
const einst = umgebung()
- const [benachrichtigung, pushGeraete] = await Promise.all([
+ const [benachrichtigung, telegramChats, pushGeraete] = await Promise.all([
db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: benutzer.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
+ perTelegram: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
@@ -37,6 +38,7 @@ export default async function EinstellungenSeite() {
zeitblockVorlaufMinuten: true,
},
}),
+ db.telegramChat.count({ where: { benutzerId: benutzer.id, meldungen: true } }),
db.pushGeraet.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
@@ -119,10 +121,13 @@ export default async function EinstellungenSeite() {
[z.quelle, z]))
+
+ const zeilen: Zeile[] = [
+ {
+ quelle: 'ZAMMAD',
+ name: 'Zammad',
+ beschreibung:
+ 'Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Geschrieben wird nach Zammad nichts — ein Ticket schließt man im Ticketsystem.',
+ vorgesehen: einst.instanz === 'firma',
+ aktiv: einst.zammadAktiv,
+ hinweis: 'ZAMMAD_URL und ZAMMAD_TOKEN fehlen.',
+ },
+ {
+ quelle: 'WARTUNGSPLANER',
+ name: 'Wartungsplaner',
+ beschreibung:
+ 'Noch nicht angebunden: Die Schnittstelle ist offen. Die Aufnahmestelle steht bereit, es fehlt allein das Abholen.',
+ vorgesehen: einst.instanz === 'firma',
+ aktiv: false,
+ hinweis: 'Kommt, sobald die Schnittstelle feststeht.',
+ },
+ {
+ quelle: 'TELEGRAM',
+ name: 'Telegram',
+ beschreibung:
+ 'Eine Nachricht an den Bot wird zur Aufgabe. Meldungen gehen denselben Weg zurück — mit einem Knopf zum Abhaken.',
+ vorgesehen: einst.instanz === 'daheim',
+ aktiv: einst.telegramAktiv,
+ hinweis: 'TELEGRAM_TOKEN fehlt.',
+ },
+ {
+ quelle: 'SPIEGEL',
+ name: 'Spiegel',
+ beschreibung:
+ einst.instanz === 'firma'
+ ? 'Schickt Belegungen ohne Titel nach Hause, dazu die Aufgaben, die ausdrücklich zum Spiegeln markiert sind.'
+ : 'Empfängt dienstliche Belegungen. Sie erscheinen in der Wochenansicht als „Dienstlich belegt", ohne Betreff.',
+ vorgesehen: true,
+ aktiv: einst.spiegelSendenAktiv || einst.spiegelEmpfangenAktiv,
+ hinweis: 'SPIEGEL_GEHEIMNIS fehlt.',
+ },
+ ]
+
+ return (
+
+
+
+
+
Integrationen
+
+ Was von außen hereinkommt — und was diese Instanz hinausgibt.
+
+
+
+
+
+ {zeilen.map((zeile) => {
+ const zustand = nachQuelle.get(zeile.quelle)
+ const gestoert = (zustand?.fehlversuche ?? 0) >= INTEGRATION_FEHLER_AB_VERSUCH
+
+ return (
+
+
+
+ {zeile.name}
+ {!zeile.vorgesehen ? (
+
+ In dieser Instanz nicht vorgesehen
+
+ ) : gestoert ? (
+ Gestört
+ ) : zeile.aktiv ? (
+ Läuft
+ ) : (
+ Nicht eingerichtet
+ )}
+
+ {zeile.beschreibung}
+
+
+
+ {zeile.vorgesehen && !zeile.aktiv && {zeile.hinweis}
}
+
+ {zustand?.letzterErfolg && (
+ Zuletzt geglückt: {alsDatumUhrzeit(zustand.letzterErfolg)}
+ )}
+ {zustand?.letzterFehler && (
+
+ Letzter Fehler: {zustand.letzterFehler}
+ {zustand.fehlversuche > 1 && ` (${zustand.fehlversuche}× in Folge)`}
+
+ )}
+ {!zustand && zeile.aktiv && Noch kein Lauf.
}
+
+
+ )
+ })}
+
+
+ {einst.telegramAktiv && (
+
+
+ Telegram-Chats
+
+ Nur wer hier steht, darf mit dem Bot reden — und nur diese Chats bekommen
+ Meldungen. Alles andere wird verworfen, ohne zu antworten. Die Kennung
+ deines Chats verrät dir der Bot, wenn du ihm{' '}
+ /start schickst und danach hier nachsiehst;
+ steht sie noch nicht drin, findest du sie im Protokoll.
+
+
+
+ ({
+ id: c.id,
+ chatId: c.chatId.toString(),
+ bezeichnung: c.bezeichnung,
+ meldungen: c.meldungen,
+ erstelltAm: alsDatumUhrzeit(c.erstelltAm),
+ zuletztBenutzt: c.zuletztBenutzt
+ ? alsDatumUhrzeit(c.zuletztBenutzt)
+ : null,
+ }))}
+ />
+
+
+ )}
+
+ )
+}
diff --git a/src/app/(app)/integrationen/telegram-chats.tsx b/src/app/(app)/integrationen/telegram-chats.tsx
new file mode 100644
index 0000000..50c349c
--- /dev/null
+++ b/src/app/(app)/integrationen/telegram-chats.tsx
@@ -0,0 +1,139 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { Bell, BellOff, Link2, Loader2, Trash2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+ chatEntfernen,
+ chatMeldungenUmschalten,
+ kopplungAnfordern,
+} from '@/server/aktionen/integrationen'
+
+export type ChatAnzeige = {
+ id: string
+ chatId: string
+ bezeichnung: string | null
+ meldungen: boolean
+ erstelltAm: string
+ zuletztBenutzt: string | null
+}
+
+export function TelegramChats({ chats }: { chats: ChatAnzeige[] }) {
+ const router = useRouter()
+ const [laeuft, starte] = useTransition()
+ const [code, setCode] = useState<{ code: string; minuten: number } | null>(null)
+
+ function anfordern() {
+ starte(async () => {
+ const antwort = await kopplungAnfordern()
+ if (antwort.ok) {
+ setCode({ code: antwort.code, minuten: antwort.minuten })
+ } else {
+ toast.error(antwort.fehler)
+ }
+ })
+ }
+
+ function mitRueckmeldung(
+ tun: () => Promise<{ ok: boolean; meldung?: string; fehler?: string }>,
+ ) {
+ starte(async () => {
+ const antwort = await tun()
+ if (antwort.ok) {
+ toast.success(antwort.meldung ?? 'Erledigt.')
+ router.refresh()
+ } else {
+ toast.error(antwort.fehler ?? 'Das hat nicht geklappt.')
+ }
+ })
+ }
+
+ return (
+
+ {chats.length === 0 ? (
+
+ Noch kein Chat verbunden. Ohne Verbindung nimmt der Bot nichts an.
+
+ ) : (
+
+ {chats.map((chat) => (
+
+
+
{chat.bezeichnung ?? `Chat ${chat.chatId}`}
+
+ Verbunden {chat.erstelltAm}
+ {chat.zuletztBenutzt
+ ? ` · zuletzt benutzt ${chat.zuletztBenutzt}`
+ : ' · noch nichts geschickt'}
+
+
+
+ {
+ const daten = new FormData()
+ daten.set('id', chat.id)
+ mitRueckmeldung(() => chatMeldungenUmschalten(daten))
+ }}
+ >
+ {chat.meldungen ? (
+
+ ) : (
+
+ )}
+ Meldungen umschalten
+
+
+ {
+ const daten = new FormData()
+ daten.set('id', chat.id)
+ mitRueckmeldung(() => chatEntfernen(daten))
+ }}
+ >
+
+ Trennen
+
+
+ ))}
+
+ )}
+
+ {code ? (
+
+
+ Schick dem Bot jetzt diesen Code — als Nachricht oder als{' '}
+ /start {code.code}:
+
+
{code.code}
+
+ Gültig für {code.minuten} Minuten und nur ein einziges Mal. Danach erscheint
+ der Chat hier — spätestens nach dem nächsten Abholen.
+
+
+ ) : (
+
+
+ {laeuft ? (
+
+ ) : (
+
+ )}
+ Chat verbinden
+
+
+ )}
+
+ )
+}
diff --git a/src/components/kopfleiste.tsx b/src/components/kopfleiste.tsx
index e00c58d..f314be0 100644
--- a/src/components/kopfleiste.tsx
+++ b/src/components/kopfleiste.tsx
@@ -35,6 +35,7 @@ export function Kopfleiste({
{ pfad: '/ideen', name: 'Ideen' },
{ pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/kalenderkonten', name: 'Kalender' },
+ { pfad: '/integrationen', name: 'Integrationen' },
{ pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
]
diff --git a/src/server/aktionen/aufgaben.ts b/src/server/aktionen/aufgaben.ts
index 2a8af5a..6fcf1ea 100644
--- a/src/server/aktionen/aufgaben.ts
+++ b/src/server/aktionen/aufgaben.ts
@@ -12,6 +12,7 @@ import {
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
+import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
/**
@@ -347,6 +348,54 @@ export async function einsortieren(daten: FormData): Promise {
return { ok: true, meldung: `„${aufgabe.titel}" liegt jetzt in ${bereich.name}.` }
}
+/**
+ * Gibt eine Aufgabe zum Spiegeln frei – oder nimmt die Freigabe zurück.
+ *
+ * Nur was hier ausdrücklich markiert ist, verlässt die Instanz mit Titel.
+ * Deshalb ein eigener Handgriff je Aufgabe und kein Schalter für alles: Wer
+ * versehentlich einen Bereich spiegelt, schickt Kundennamen nach Hause.
+ */
+export async function spiegelnUmschalten(daten: FormData): Promise {
+ const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
+ if ('antwort' in geprueft) return geprueft.antwort
+ const { akteur } = geprueft
+
+ if (!umgebung().spiegelSendenAktiv) {
+ return { ok: false, fehler: 'Diese Instanz spiegelt nicht.' }
+ }
+
+ const id = daten.get('id')
+ if (typeof id !== 'string' || id.length === 0) {
+ return { ok: false, fehler: 'Ungültige Anfrage.' }
+ }
+
+ const aufgabe = await db.aufgabe.findUnique({
+ where: { id },
+ select: { id: true, titel: true, spiegeln: true },
+ })
+ if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' }
+
+ const kuenftig = !aufgabe.spiegeln
+ await db.aufgabe.update({ where: { id: aufgabe.id }, data: { spiegeln: kuenftig } })
+
+ await auditSchreiben({
+ akteur: { id: akteur.id, email: akteur.email },
+ aktion: kuenftig ? 'aufgabe.spiegeln-an' : 'aufgabe.spiegeln-aus',
+ objektTyp: 'Aufgabe',
+ objektId: aufgabe.id,
+ details: { titel: aufgabe.titel },
+ })
+
+ revalidatePath('/aufgaben')
+ revalidatePath('/inbox')
+ return {
+ ok: true,
+ meldung: kuenftig
+ ? `„${aufgabe.titel}" geht künftig mit nach Hause.`
+ : `„${aufgabe.titel}" bleibt hier.`,
+ }
+}
+
/** Aus einer Idee wird eine Aufgabe. */
export async function ideeUebernehmen(daten: FormData): Promise {
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
diff --git a/src/server/aktionen/benachrichtigungen.ts b/src/server/aktionen/benachrichtigungen.ts
index ca7f71e..2385591 100644
--- a/src/server/aktionen/benachrichtigungen.ts
+++ b/src/server/aktionen/benachrichtigungen.ts
@@ -8,6 +8,7 @@ import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { mailSenden } from '@/server/benachrichtigung/mail'
import { pushSenden } from '@/server/benachrichtigung/push'
import { alsEinstellung } from '@/server/benachrichtigung/versand'
+import { telegramMeldung } from '@/server/integration/telegram'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
@@ -37,6 +38,7 @@ export async function einstellungSpeichern(daten: FormData): Promise {
anlaesse: true,
perPush: true,
perMail: true,
+ perTelegram: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
@@ -112,8 +117,11 @@ export async function probeSenden(): Promise {
})
const einstellung = alsEinstellung(zeile)
- if (!einstellung.perPush && !einstellung.perMail) {
- return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
+ if (!einstellung.perPush && !einstellung.perMail && !einstellung.perTelegram) {
+ return {
+ ok: false,
+ fehler: 'Erst einen Weg einschalten — Push, E-Mail oder Telegram.',
+ }
}
const meldung = {
@@ -153,6 +161,19 @@ export async function probeSenden(): Promise {
}
}
+ if (einstellung.perTelegram) {
+ if (!einst.telegramAktiv) {
+ wege.push('Telegram ist auf diesem Server nicht eingerichtet')
+ } else {
+ const zugestellt = await telegramMeldung(akteur.id, meldung)
+ wege.push(
+ zugestellt > 0
+ ? `Telegram an ${zugestellt} Chat(s)`
+ : 'kein Telegram-Chat eingetragen',
+ )
+ }
+ }
+
await auditSchreiben({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'benachrichtigung.probe',
diff --git a/src/server/aktionen/integrationen.ts b/src/server/aktionen/integrationen.ts
new file mode 100644
index 0000000..db2db95
--- /dev/null
+++ b/src/server/aktionen/integrationen.ts
@@ -0,0 +1,191 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { auditSchreiben } from '@/server/audit'
+import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
+import { db } from '@/server/db'
+import {
+ KOPPLUNG_MINUTEN,
+ kopplungscodeBauen,
+ telegramAbholen,
+} from '@/server/integration/telegram'
+import { zammadAbgleichen } from '@/server/integration/zammad'
+import { spiegelSenden } from '@/server/integration/spiegel'
+import { umgebung } from '@/server/umgebung'
+import { jetzt } from '@/server/zeitgeber'
+
+export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string }
+
+async function akteurOderFehler() {
+ try {
+ return { akteur: await pruefeRolle('BENUTZER') }
+ } catch (fehler) {
+ if (fehler instanceof NichtErlaubt) {
+ return { antwort: { ok: false as const, fehler: fehler.message } }
+ }
+ throw fehler
+ }
+}
+
+/**
+ * Erzeugt einen Kopplungscode für einen Telegram-Chat.
+ *
+ * Alte Codes desselben Benutzers fallen dabei weg: Zwei gültige Codes
+ * gleichzeitig sind einer zu viel.
+ */
+export async function kopplungAnfordern(): Promise<
+ { ok: true; code: string; minuten: number } | { ok: false; fehler: string }
+> {
+ const geprueft = await akteurOderFehler()
+ if ('antwort' in geprueft) return geprueft.antwort
+ const { akteur } = geprueft
+
+ if (!umgebung().telegramAktiv) {
+ return { ok: false, fehler: 'Telegram ist auf diesem Server nicht eingerichtet.' }
+ }
+
+ await db.telegramKopplung.deleteMany({ where: { benutzerId: akteur.id } })
+
+ const code = kopplungscodeBauen()
+ await db.telegramKopplung.create({
+ data: {
+ code,
+ benutzerId: akteur.id,
+ gueltigBis: new Date(jetzt().getTime() + KOPPLUNG_MINUTEN * 60_000),
+ },
+ })
+
+ await auditSchreiben({
+ akteur: { id: akteur.id, email: akteur.email },
+ aktion: 'telegram.kopplung-angefordert',
+ objektTyp: 'TelegramKopplung',
+ objektId: null,
+ // Nie der Code selbst: Wer das Protokoll liest, könnte sonst koppeln.
+ })
+
+ return { ok: true, code, minuten: KOPPLUNG_MINUTEN }
+}
+
+/** Trennt einen Chat wieder. */
+export async function chatEntfernen(daten: FormData): Promise {
+ const geprueft = await akteurOderFehler()
+ if ('antwort' in geprueft) return geprueft.antwort
+ const { akteur } = geprueft
+
+ const id = daten.get('id')
+ if (typeof id !== 'string' || id.length === 0) {
+ return { ok: false, fehler: 'Ungültige Anfrage.' }
+ }
+
+ const chat = await db.telegramChat.findUnique({
+ where: { id },
+ select: { id: true, bezeichnung: true },
+ })
+ if (!chat) return { ok: false, fehler: 'Diesen Chat gibt es nicht.' }
+
+ await db.telegramChat.delete({ where: { id: chat.id } })
+
+ await auditSchreiben({
+ akteur: { id: akteur.id, email: akteur.email },
+ aktion: 'telegram.getrennt',
+ objektTyp: 'TelegramChat',
+ objektId: chat.id,
+ })
+
+ revalidatePath('/integrationen')
+ return { ok: true, meldung: 'Chat getrennt.' }
+}
+
+/** Schaltet um, ob ein Chat Meldungen bekommt. */
+export async function chatMeldungenUmschalten(daten: FormData): Promise {
+ const geprueft = await akteurOderFehler()
+ if ('antwort' in geprueft) return geprueft.antwort
+ const { akteur } = geprueft
+
+ const id = daten.get('id')
+ if (typeof id !== 'string' || id.length === 0) {
+ return { ok: false, fehler: 'Ungültige Anfrage.' }
+ }
+
+ const chat = await db.telegramChat.findUnique({
+ where: { id },
+ select: { id: true, meldungen: true },
+ })
+ if (!chat) return { ok: false, fehler: 'Diesen Chat gibt es nicht.' }
+
+ const kuenftig = !chat.meldungen
+ await db.telegramChat.update({
+ where: { id: chat.id },
+ data: { meldungen: kuenftig },
+ })
+
+ await auditSchreiben({
+ akteur: { id: akteur.id, email: akteur.email },
+ aktion: kuenftig ? 'telegram.meldungen-an' : 'telegram.meldungen-aus',
+ objektTyp: 'TelegramChat',
+ objektId: chat.id,
+ })
+
+ revalidatePath('/integrationen')
+ return {
+ ok: true,
+ meldung: kuenftig
+ ? 'Dieser Chat bekommt Meldungen.'
+ : 'Dieser Chat bekommt keine Meldungen mehr.',
+ }
+}
+
+/**
+ * Stößt einen Lauf von Hand an.
+ *
+ * Nützlich nach dem Einrichten: Sonst wüsste man erst beim nächsten Takt, ob
+ * die Zugangsdaten stimmen.
+ */
+export async function laufAnstossen(daten: FormData): Promise {
+ const geprueft = await akteurOderFehler()
+ if ('antwort' in geprueft) return geprueft.antwort
+ const { akteur } = geprueft
+
+ const quelle = daten.get('quelle')
+ const anstoss = { id: akteur.id, email: akteur.email }
+ const einst = umgebung()
+
+ try {
+ if (quelle === 'ZAMMAD' && einst.zammadAktiv) {
+ const bericht = await zammadAbgleichen(anstoss)
+ revalidatePath('/integrationen')
+ revalidatePath('/aufgaben')
+ return {
+ ok: true,
+ meldung: `${bericht.angelegt} neu, ${bericht.aktualisiert} geändert, ${bericht.geschlossen} geschlossen.`,
+ }
+ }
+
+ if (quelle === 'TELEGRAM' && einst.telegramAktiv) {
+ const bericht = await telegramAbholen(anstoss)
+ revalidatePath('/integrationen')
+ revalidatePath('/aufgaben')
+ return {
+ ok: true,
+ meldung: `${bericht.aufgaben} Aufgabe(n), ${bericht.gekoppelt} Kopplung(en), ${bericht.abgehakt} abgehakt.`,
+ }
+ }
+
+ if (quelle === 'SPIEGEL' && einst.spiegelSendenAktiv) {
+ const bericht = await spiegelSenden()
+ revalidatePath('/integrationen')
+ return {
+ ok: true,
+ meldung: `${bericht.belegungen} Belegung(en) und ${bericht.aufgaben} Aufgabe(n) geschickt.`,
+ }
+ }
+
+ return { ok: false, fehler: 'Diese Integration ist hier nicht eingerichtet.' }
+ } catch (fehler) {
+ // Die Meldung des fremden Systems, nicht dessen Antwort im Ganzen.
+ return {
+ ok: false,
+ fehler: fehler instanceof Error ? fehler.message : 'Der Lauf ist gescheitert.',
+ }
+ }
+}
From 0d0f8503ccf8efcd5aa9dc26f8f7430c2e08cb8d Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:04:35 +0200
Subject: [PATCH 06/14] feat: Gespiegelte Belegung in der Wochenansicht zeigen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sie zaehlt gegen die Tageskapazitaet wie jeder fremde Termin, traegt
aber keinen Betreff und ist gestreift gezeichnet — damit niemand darin
einen Termin vermutet, dem der Titel fehlt.
Co-Authored-By: Claude Opus 5
---
src/app/(app)/woche/page.tsx | 1 +
src/app/(app)/woche/wochenraster.tsx | 17 ++++++++--
src/server/abfragen/woche.ts | 48 +++++++++++++++++++++-------
3 files changed, 53 insertions(+), 13 deletions(-)
diff --git a/src/app/(app)/woche/page.tsx b/src/app/(app)/woche/page.tsx
index a7c7430..19f7ef2 100644
--- a/src/app/(app)/woche/page.tsx
+++ b/src/app/(app)/woche/page.tsx
@@ -187,6 +187,7 @@ export default async function WocheSeite({
ganztaegig: t.ganztaegig,
frei: t.zeigtAls === 'FREI',
kalenderName: t.kalenderName,
+ gespiegelt: t.gespiegelt,
}))}
aufgaben={aufgaben.map((a) => ({
id: a.id,
diff --git a/src/app/(app)/woche/wochenraster.tsx b/src/app/(app)/woche/wochenraster.tsx
index d1de1ed..a4926cc 100644
--- a/src/app/(app)/woche/wochenraster.tsx
+++ b/src/app/(app)/woche/wochenraster.tsx
@@ -64,6 +64,8 @@ export type RasterTermin = {
ganztaegig: boolean
frei: boolean
kalenderName: string
+ /** Aus der anderen Instanz gespiegelt: belegt, aber ohne Betreff. */
+ gespiegelt?: boolean
}
export type RasterSpalte = {
@@ -321,6 +323,11 @@ export function Wochenraster({
eintrag.frei
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
: 'bg-gedaempft text-gedaempft-vordergrund',
+ // Gespiegeltes gehört sichtbar in eine eigene
+ // Klasse: gestreift, damit niemand darin einen
+ // Termin mit fehlendem Betreff vermutet.
+ eintrag.gespiegelt &&
+ 'border-rand border border-dashed bg-[repeating-linear-gradient(45deg,transparent,transparent_4px,color-mix(in_oklch,currentColor_12%,transparent)_4px,color-mix(in_oklch,currentColor_12%,transparent)_8px)]',
)}
style={{
top: lage.oben,
@@ -328,10 +335,16 @@ export function Wochenraster({
left: `${(spur / spuren) * 100}%`,
width: `${100 / spuren}%`,
}}
- title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
+ title={
+ eintrag.gespiegelt
+ ? 'Dienstlich belegt – gespiegelt aus der anderen Instanz'
+ : `${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`
+ }
>
- {eintrag.betreff ?? 'Termin'}
+ {eintrag.gespiegelt
+ ? 'Dienstlich belegt'
+ : (eintrag.betreff ?? 'Termin')}
)
diff --git a/src/server/abfragen/woche.ts b/src/server/abfragen/woche.ts
index bbb7f0d..5493985 100644
--- a/src/server/abfragen/woche.ts
+++ b/src/server/abfragen/woche.ts
@@ -33,6 +33,13 @@ export type WochenTermin = {
ganztaegig: boolean
zeigtAls: ZeigtAls
kalenderName: string
+ /**
+ * Kommt aus der anderen Instanz und trägt darum keinen Betreff.
+ *
+ * Für die Ansicht ist so eine Belegung ein fremder Termin wie jeder andere:
+ * Sie blockiert Zeit und lässt sich hier nicht verschieben.
+ */
+ gespiegelt: boolean
}
export type WocheDaten = {
@@ -55,7 +62,7 @@ export async function wocheLaden(
): Promise {
const fenster = wochenFenster(zeitpunkt)
- const [zeitbloecke, termine] = await Promise.all([
+ const [zeitbloecke, termine, belegungen] = await Promise.all([
db.zeitblock.findMany({
where: {
kalenderkonto: { benutzerId },
@@ -97,6 +104,11 @@ export async function wocheLaden(
kalenderkonto: { select: { anzeigename: true } },
},
}),
+ db.spiegelbelegung.findMany({
+ where: { beginn: { lt: fenster.bis }, ende: { gt: fenster.von } },
+ orderBy: { beginn: 'asc' },
+ select: { id: true, beginn: true, ende: true },
+ }),
])
// Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst
@@ -118,17 +130,31 @@ export async function wocheLaden(
letzterFehler: z.letzterFehler,
offen: z.extId === null,
})),
- termine: termine
- .filter((t) => !eigeneExtIds.has(t.extId))
- .map((t) => ({
- id: t.id,
- beginn: t.beginn,
- ende: t.ende,
- betreff: t.betreff,
- ganztaegig: t.ganztaegig,
- zeigtAls: t.zeigtAls,
- kalenderName: t.kalenderkonto.anzeigename,
+ termine: [
+ ...termine
+ .filter((t) => !eigeneExtIds.has(t.extId))
+ .map((t) => ({
+ id: t.id,
+ beginn: t.beginn,
+ ende: t.ende,
+ betreff: t.betreff,
+ ganztaegig: t.ganztaegig,
+ zeigtAls: t.zeigtAls,
+ kalenderName: t.kalenderkonto.anzeigename,
+ gespiegelt: false,
+ })),
+ ...belegungen.map((b) => ({
+ id: b.id,
+ beginn: b.beginn,
+ ende: b.ende,
+ // Kein Betreff: Die andere Instanz schickt bewusst keinen.
+ betreff: null,
+ ganztaegig: false,
+ zeigtAls: 'GEBUCHT' as const,
+ kalenderName: 'Dienstlich',
+ gespiegelt: true,
})),
+ ].sort((a, b) => a.beginn.getTime() - b.beginn.getTime()),
}
}
From 4da2f3d9c6a92ed620948f0e3eac1fcd32aa7b68 Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:58:43 +0200
Subject: [PATCH 07/14] test: Spiegel-Eingang und Integrationstabellen
absichern
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Der Eingang des Spiegels ist die einzige Tuer, die ohne Anmeldung
aufgeht. Vier Faelle: ohne Signatur, falsch signiert, zu alt, und der
gute Fall — bei dem eine nicht mehr geschickte Belegung auch hier
verschwinden muss.
Der Testserver bekommt dafuer dasselbe Geheimnis wie der Test. Eine
Pruefung, die immer uebersprungen wird, prueft nichts.
Co-Authored-By: Claude Opus 5
---
e2e/hilfe.ts | 10 +++
e2e/spiegel.spec.ts | 138 ++++++++++++++++++++++++++++++++++++
playwright.config.ts | 3 +
scripts/integrationstest.ts | 83 ++++++++++++++++++++++
4 files changed, 234 insertions(+)
create mode 100644 e2e/spiegel.spec.ts
diff --git a/e2e/hilfe.ts b/e2e/hilfe.ts
index d5b601b..965a681 100644
--- a/e2e/hilfe.ts
+++ b/e2e/hilfe.ts
@@ -6,6 +6,16 @@ export const EMAIL = process.env.ADMIN_EMAIL ?? ''
export const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
export const VERBINDUNG = process.env.DATABASE_URL ?? ''
+/**
+ * Geheimnis fuer den Spiegel im Test.
+ *
+ * Der Testserver bekommt denselben Wert ueber playwright.config.ts, sonst
+ * liesse sich die Tuer gar nicht pruefen — und eine Pruefung, die immer
+ * uebersprungen wird, prueft nichts.
+ */
+export const SPIEGEL_GEHEIMNIS =
+ process.env.SPIEGEL_GEHEIMNIS ?? 'e2e-spiegel-geheimnis-nur-lokal'
+
/**
* Meldet das Testkonto mit Passwort an.
*
diff --git a/e2e/spiegel.spec.ts b/e2e/spiegel.spec.ts
new file mode 100644
index 0000000..c000580
--- /dev/null
+++ b/e2e/spiegel.spec.ts
@@ -0,0 +1,138 @@
+import { createHmac } from 'node:crypto'
+import { PrismaPg } from '@prisma/adapter-pg'
+import { expect, test } from '@playwright/test'
+import { PrismaClient } from '../src/generated/prisma/client.js'
+import { SPIEGEL_GEHEIMNIS, VERBINDUNG } from './hilfe'
+
+/**
+ * Der Eingang des Spiegels.
+ *
+ * Hier meldet sich kein Mensch an — die Gegenstelle weist sich allein über
+ * eine Signatur aus. Deshalb wird hier nicht die Oberfläche geprüft, sondern
+ * genau diese Tür: Sie muss zu sein, wenn irgendetwas nicht stimmt.
+ */
+
+const GEHEIMNIS = SPIEGEL_GEHEIMNIS
+const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
+
+const PRAEFIX = `E2E-S-${Date.now()}`
+
+function signieren(koerper: string, geheimnis = GEHEIMNIS): string {
+ return createHmac('sha256', geheimnis).update(koerper).digest('hex')
+}
+
+function sendung(teile: Record = {}): string {
+ return JSON.stringify({
+ gesendetAm: new Date().toISOString(),
+ belegungen: [],
+ aufgaben: [],
+ ...teile,
+ })
+}
+
+test.describe('Spiegel', () => {
+ test.afterAll(async () => {
+ await db.spiegelbelegung.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
+ await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
+ await db.auditEintrag.deleteMany({ where: { aktion: 'spiegel.empfangen' } })
+ await db.$disconnect()
+ })
+
+ test('weist eine Sendung ohne Signatur ab', async ({ request }) => {
+ const antwort = await request.post('/api/spiegel', {
+ headers: { 'Content-Type': 'application/json' },
+ data: sendung(),
+ })
+ expect(antwort.status()).toBe(401)
+ })
+
+ test('weist eine falsch signierte Sendung ab', async ({ request }) => {
+ const koerper = sendung()
+ const antwort = await request.post('/api/spiegel', {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Planer-Signatur': signieren(koerper, 'das-ist-nicht-das-geheimnis'),
+ },
+ data: koerper,
+ })
+ expect(antwort.status()).toBe(401)
+ })
+
+ test('weist eine Sendung ab, die zu lange unterwegs war', async ({ request }) => {
+ test.skip(!GEHEIMNIS, 'Ohne SPIEGEL_GEHEIMNIS ist der Eingang ohnehin zu.')
+
+ // Eine Stunde alt: richtig signiert, trotzdem wertlos. Sonst ließe sich
+ // ein abgefangenes Paket beliebig oft erneut einspielen.
+ const koerper = sendung({
+ gesendetAm: new Date(Date.now() - 3_600_000).toISOString(),
+ })
+ const antwort = await request.post('/api/spiegel', {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Planer-Signatur': signieren(koerper),
+ },
+ data: koerper,
+ })
+ expect(antwort.status()).toBe(401)
+ })
+
+ test('nimmt eine gültige Sendung an und räumt Veraltetes weg', async ({ request }) => {
+ test.skip(
+ !GEHEIMNIS,
+ 'Ohne SPIEGEL_GEHEIMNIS auf dem Testserver lässt sich das nicht prüfen.',
+ )
+
+ const beginn = new Date(Date.now() + 86_400_000)
+ const ende = new Date(beginn.getTime() + 3_600_000)
+
+ const erste = sendung({
+ belegungen: [
+ {
+ ref: `${PRAEFIX}-a`,
+ beginn: beginn.toISOString(),
+ ende: ende.toISOString(),
+ },
+ ],
+ aufgaben: [{ ref: `${PRAEFIX}-1`, titel: `${PRAEFIX} Angebot schreiben` }],
+ })
+
+ const antwort = await request.post('/api/spiegel', {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Planer-Signatur': signieren(erste),
+ },
+ data: erste,
+ })
+ expect(antwort.ok()).toBe(true)
+
+ expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe(
+ 1,
+ )
+ const aufgabe = await db.aufgabe.findFirst({
+ where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` },
+ select: { titel: true },
+ })
+ expect(aufgabe?.titel).toBe(`${PRAEFIX} Angebot schreiben`)
+
+ // Zweite Runde ohne die Belegung: Sie wurde drüben ausgeplant und muss
+ // auch hier verschwinden — sonst stünde für immer eine Sperre, die es
+ // längst nicht mehr gibt.
+ const zweite = sendung({ aufgaben: [] })
+ const nachher = await request.post('/api/spiegel', {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Planer-Signatur': signieren(zweite),
+ },
+ data: zweite,
+ })
+ expect(nachher.ok()).toBe(true)
+ expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe(
+ 0,
+ )
+
+ // Die Aufgabe bleibt: Sie ist Planung, keine Sperre.
+ expect(
+ await db.aufgabe.count({ where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` } }),
+ ).toBe(1)
+ })
+})
diff --git a/playwright.config.ts b/playwright.config.ts
index e7de6a3..6cf733a 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,5 +1,6 @@
import { defineConfig, devices } from '@playwright/test'
import 'dotenv/config'
+import { SPIEGEL_GEHEIMNIS } from './e2e/hilfe'
const PORT = Number(process.env.E2E_PORT ?? 3100)
const BASIS = `http://127.0.0.1:${PORT}`
@@ -41,6 +42,8 @@ export default defineConfig({
APP_URL: BASIS,
WEBAUTHN_ORIGIN: BASIS,
WEBAUTHN_RP_ID: '127.0.0.1',
+ // Ohne Geheimnis waere der Spiegel-Eingang zu und sein Test sinnlos.
+ SPIEGEL_GEHEIMNIS,
},
},
})
diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts
index 58b4cd6..889f400 100644
--- a/scripts/integrationstest.ts
+++ b/scripts/integrationstest.ts
@@ -41,6 +41,9 @@ async function scheitert(name: string, tun: () => Promise): Promise {
@@ -58,6 +61,12 @@ async function aufraeumen(): Promise {
await db.webauthnChallenge.deleteMany({ where: { kennung } })
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } })
+ await db.spiegelbelegung.deleteMany({ where: { quelleRef: { contains: kennung } } })
+ await db.telegramKopplung.deleteMany({
+ where: { code: { contains: kennung.slice(-8) } },
+ })
+ await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
+ await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
}
async function main(): Promise {
@@ -316,6 +325,76 @@ async function main(): Promise {
).id.length > 0,
)
+ // --- Integrationen -------------------------------------------------------
+ // Der Zustand haengt an der Quelle: eine Zeile je Integration, nicht mehr.
+ await db.integrationszustand.create({
+ data: { quelle: 'TELEGRAM', merker: '42' },
+ })
+ await scheitert('Je Quelle nur ein Integrationszustand', () =>
+ db.integrationszustand.create({ data: { quelle: 'TELEGRAM' } }),
+ )
+ await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
+
+ // Zwei Aufgaben aus derselben Quelle mit derselben Kennung darf es nicht
+ // geben – sonst legte jeder Lauf dieselbe Sache erneut an.
+ const ausZammad = await db.aufgabe.create({
+ data: { titel: `${kennung}-ticket`, quelle: 'ZAMMAD', quelleRef: `${kennung}-1` },
+ })
+ pruefe('Aufgabe aus einer Integration anlegen', ausZammad.id.length > 0)
+ pruefe('Ohne Freigabe wird nicht gespiegelt', ausZammad.spiegeln === false)
+
+ await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal (Integration)', () =>
+ db.aufgabe.create({
+ data: {
+ titel: `${kennung}-ticket-doppelt`,
+ quelle: 'ZAMMAD',
+ quelleRef: `${kennung}-1`,
+ },
+ }),
+ )
+
+ await db.spiegelbelegung.create({
+ data: {
+ quelleRef: `${kennung}-zb`,
+ beginn: new Date(Date.UTC(2026, 2, 17, 8, 0)),
+ ende: new Date(Date.UTC(2026, 2, 17, 9, 0)),
+ },
+ })
+ await scheitert('Dieselbe gespiegelte Belegung nur einmal', () =>
+ db.spiegelbelegung.create({
+ data: {
+ quelleRef: `${kennung}-zb`,
+ beginn: new Date(Date.UTC(2026, 2, 18, 8, 0)),
+ ende: new Date(Date.UTC(2026, 2, 18, 9, 0)),
+ },
+ }),
+ )
+
+ const chat = await db.telegramChat.create({
+ data: { chatId: testChatId, benutzerId: benutzer.id },
+ })
+ pruefe('Telegram-Chat empfaengt im Zweifel Meldungen', chat.meldungen === true)
+ await scheitert('Dieselbe Chatkennung nur einmal', () =>
+ db.telegramChat.create({ data: { chatId: chat.chatId } }),
+ )
+
+ await db.telegramKopplung.create({
+ data: {
+ code: `${kennung.slice(-8)}`,
+ benutzerId: benutzer.id,
+ gueltigBis: new Date(Date.now() + 600_000),
+ },
+ })
+ await scheitert('Denselben Kopplungscode nur einmal', () =>
+ db.telegramKopplung.create({
+ data: {
+ code: `${kennung.slice(-8)}`,
+ benutzerId: benutzer.id,
+ gueltigBis: new Date(Date.now() + 600_000),
+ },
+ }),
+ )
+
await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
@@ -341,6 +420,10 @@ async function main(): Promise {
'Push-Geräte verschwinden mit dem Benutzer',
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
)
+ pruefe(
+ 'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
+ (await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
+ )
pruefe(
'Meldungsnachweise verschwinden mit dem Benutzer',
(await db.meldungsnachweis.count({
From 3befcc19a35d535311d0b1f7aa249b2587138460 Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:58:46 +0200
Subject: [PATCH 08/14] feat: Aufgaben einzeln zum Spiegeln freigeben
Der Schalter erscheint nur, wo auch gespiegelt wird.
Co-Authored-By: Claude Opus 5
---
src/app/(app)/aufgaben/page.tsx | 4 ++++
src/lib/aufgabe-anzeige.test.ts | 1 +
src/lib/aufgabe-anzeige.ts | 4 ++++
src/server/abfragen/aufgaben.ts | 1 +
4 files changed, 10 insertions(+)
diff --git a/src/app/(app)/aufgaben/page.tsx b/src/app/(app)/aufgaben/page.tsx
index ee47777..d46ec4a 100644
--- a/src/app/(app)/aufgaben/page.tsx
+++ b/src/app/(app)/aufgaben/page.tsx
@@ -14,6 +14,7 @@ import {
type AufgabenFilter,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
+import { umgebung } from '@/server/umgebung'
export const metadata: Metadata = { title: 'Aufgaben' }
@@ -67,6 +68,7 @@ export default async function AufgabenSeite({
searchParams: Promise>
}) {
await verlangeAnmeldung('/aufgaben')
+ const spiegelt = umgebung().spiegelSendenAktiv
const parameter = await searchParams
const [aufgaben, bereiche, projekte] = await Promise.all([
@@ -101,11 +103,13 @@ export default async function AufgabenSeite({
+ {/* Nur wo auch gespiegelt wird, hat der Schalter einen Sinn. */}
)
diff --git a/src/lib/aufgabe-anzeige.test.ts b/src/lib/aufgabe-anzeige.test.ts
index 2d78313..69d7774 100644
--- a/src/lib/aufgabe-anzeige.test.ts
+++ b/src/lib/aufgabe-anzeige.test.ts
@@ -16,6 +16,7 @@ function roh(teil: Partial = {}): AufgabeRoh {
faelligAm: null,
quelle: 'MANUELL',
quelleUrl: null,
+ spiegeln: false,
bereich: null,
projekt: null,
...teil,
diff --git a/src/lib/aufgabe-anzeige.ts b/src/lib/aufgabe-anzeige.ts
index 2403aa7..b5aeffa 100644
--- a/src/lib/aufgabe-anzeige.ts
+++ b/src/lib/aufgabe-anzeige.ts
@@ -26,6 +26,7 @@ export type AufgabeRoh = {
faelligAm: Date | null
quelle: Quelle
quelleUrl: string | null
+ spiegeln: boolean
bereich: { id: string; name: string; farbe: string } | null
projekt: { id: string; name: string } | null
}
@@ -48,6 +49,8 @@ export type AufgabeAnzeige = {
ueberfaellig: boolean
quelle: Quelle
quelleUrl: string | null
+ /** Ausdruecklich zum Spiegeln freigegeben. */
+ spiegeln: boolean
bereich: { id: string; name: string; farbe: string } | null
projekt: { id: string; name: string } | null
}
@@ -79,6 +82,7 @@ export function fuerAnzeige(aufgabe: AufgabeRoh, jetzt: Date): AufgabeAnzeige {
ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt),
quelle: aufgabe.quelle,
quelleUrl: aufgabe.quelleUrl,
+ spiegeln: aufgabe.spiegeln,
bereich: aufgabe.bereich,
projekt: aufgabe.projekt,
}
diff --git a/src/server/abfragen/aufgaben.ts b/src/server/abfragen/aufgaben.ts
index 8e0a43c..98a0195 100644
--- a/src/server/abfragen/aufgaben.ts
+++ b/src/server/abfragen/aufgaben.ts
@@ -25,6 +25,7 @@ const AUSWAHL = {
faelligAm: true,
quelle: true,
quelleUrl: true,
+ spiegeln: true,
bereich: { select: { id: true, name: true, farbe: true } },
projekt: { select: { id: true, name: true } },
} satisfies Prisma.AufgabeSelect
From f5eadefdbb88e7b6644889d41d7338e6abd9ef8c Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Fri, 11 Sep 2026 23:59:14 +0200
Subject: [PATCH 09/14] feat: Lauf von Hand anstossen und Spiegelschalter in
der Liste
Direkt nach dem Einrichten wuesste man sonst erst beim naechsten Takt,
ob die Zugangsdaten stimmen.
Co-Authored-By: Claude Opus 5
---
src/app/(app)/integrationen/lauf-knopf.tsx | 46 ++++++++++++++++++++++
src/app/(app)/integrationen/page.tsx | 13 ++++++
src/components/aufgaben/aufgaben-liste.tsx | 4 ++
src/components/aufgaben/aufgaben-zeile.tsx | 37 ++++++++++++++++-
4 files changed, 99 insertions(+), 1 deletion(-)
create mode 100644 src/app/(app)/integrationen/lauf-knopf.tsx
diff --git a/src/app/(app)/integrationen/lauf-knopf.tsx b/src/app/(app)/integrationen/lauf-knopf.tsx
new file mode 100644
index 0000000..35198af
--- /dev/null
+++ b/src/app/(app)/integrationen/lauf-knopf.tsx
@@ -0,0 +1,46 @@
+'use client'
+
+import { useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { Loader2, RefreshCw } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { laufAnstossen } from '@/server/aktionen/integrationen'
+
+/**
+ * Stößt einen Lauf von Hand an.
+ *
+ * Vor allem direkt nach dem Einrichten: Sonst wüsste man erst beim nächsten
+ * Takt, ob die Zugangsdaten stimmen.
+ */
+export function LaufKnopf({ quelle }: { quelle: string }) {
+ const router = useRouter()
+ const [laeuft, starte] = useTransition()
+
+ return (
+ {
+ starte(async () => {
+ const daten = new FormData()
+ daten.set('quelle', quelle)
+ const antwort = await laufAnstossen(daten)
+ if (antwort.ok) {
+ toast.success(antwort.meldung)
+ router.refresh()
+ } else {
+ toast.error(antwort.fehler)
+ }
+ })
+ }}
+ >
+ {laeuft ? (
+
+ ) : (
+
+ )}
+ Jetzt abrufen
+
+ )
+}
diff --git a/src/app/(app)/integrationen/page.tsx b/src/app/(app)/integrationen/page.tsx
index 079483d..c768703 100644
--- a/src/app/(app)/integrationen/page.tsx
+++ b/src/app/(app)/integrationen/page.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import { Plug } from 'lucide-react'
+import { LaufKnopf } from '@/app/(app)/integrationen/lauf-knopf'
import { TelegramChats } from '@/app/(app)/integrationen/telegram-chats'
import {
Card,
@@ -27,6 +28,8 @@ type Zeile = {
/** Und auch eingerichtet? */
aktiv: boolean
hinweis: string
+ /** Laesst sich der Lauf von Hand anstossen? Der Empfaenger wartet nur. */
+ abrufbar?: boolean
}
export default async function IntegrationenSeite() {
@@ -59,6 +62,7 @@ export default async function IntegrationenSeite() {
vorgesehen: einst.instanz === 'firma',
aktiv: einst.zammadAktiv,
hinweis: 'ZAMMAD_URL und ZAMMAD_TOKEN fehlen.',
+ abrufbar: einst.zammadAktiv,
},
{
quelle: 'WARTUNGSPLANER',
@@ -77,6 +81,7 @@ export default async function IntegrationenSeite() {
vorgesehen: einst.instanz === 'daheim',
aktiv: einst.telegramAktiv,
hinweis: 'TELEGRAM_TOKEN fehlt.',
+ abrufbar: einst.telegramAktiv,
},
{
quelle: 'SPIEGEL',
@@ -88,6 +93,8 @@ export default async function IntegrationenSeite() {
vorgesehen: true,
aktiv: einst.spiegelSendenAktiv || einst.spiegelEmpfangenAktiv,
hinweis: 'SPIEGEL_GEHEIMNIS fehlt.',
+ // Die empfangende Seite ruft nichts ab – sie wartet.
+ abrufbar: einst.spiegelSendenAktiv,
},
]
@@ -141,6 +148,12 @@ export default async function IntegrationenSeite() {
)}
{!zustand && zeile.aktiv && Noch kein Lauf.
}
+
+ {zeile.abrufbar && (
+
+
+
+ )}
)
diff --git a/src/components/aufgaben/aufgaben-liste.tsx b/src/components/aufgaben/aufgaben-liste.tsx
index 4e3dfac..29784d4 100644
--- a/src/components/aufgaben/aufgaben-liste.tsx
+++ b/src/components/aufgaben/aufgaben-liste.tsx
@@ -14,6 +14,8 @@ type Eigenschaften = {
leerText: string
mitEinsortieren?: boolean
mitUebernehmen?: boolean
+ /** Schalter zum Spiegeln zeigen; nur auf der sendenden Instanz sinnvoll. */
+ mitSpiegeln?: boolean
/** Bearbeiten direkt in der Liste erlauben. */
mitBearbeiten?: boolean
}
@@ -25,6 +27,7 @@ export function AufgabenListe({
leerText,
mitEinsortieren = false,
mitUebernehmen = false,
+ mitSpiegeln = false,
mitBearbeiten = true,
}: Eigenschaften) {
const [bearbeitet, setBearbeitet] = useState(null)
@@ -60,6 +63,7 @@ export function AufgabenListe({
bereiche={bereiche}
mitEinsortieren={mitEinsortieren}
mitUebernehmen={mitUebernehmen}
+ mitSpiegeln={mitSpiegeln}
{...(mitBearbeiten
? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
: {})}
diff --git a/src/components/aufgaben/aufgaben-zeile.tsx b/src/components/aufgaben/aufgaben-zeile.tsx
index 73476a1..752adac 100644
--- a/src/components/aufgaben/aufgaben-zeile.tsx
+++ b/src/components/aufgaben/aufgaben-zeile.tsx
@@ -3,7 +3,15 @@
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
-import { Check, ExternalLink, Loader2, Pencil, RotateCcw, Trash2 } from 'lucide-react'
+import {
+ Check,
+ ExternalLink,
+ Loader2,
+ Pencil,
+ RotateCcw,
+ Share2,
+ Trash2,
+} from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Kennzeichen } from '@/components/ui/kennzeichen'
@@ -22,6 +30,7 @@ import {
aufgabeLoeschen,
einsortieren,
ideeUebernehmen,
+ spiegelnUmschalten,
statusSetzen,
type Rueckmeldung,
} from '@/server/aktionen/aufgaben'
@@ -36,6 +45,8 @@ type Eigenschaften = {
mitEinsortieren?: boolean
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
mitUebernehmen?: boolean
+ /** Zeigt den Schalter zum Spiegeln; nur auf der sendenden Instanz. */
+ mitSpiegeln?: boolean
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
}
@@ -44,6 +55,7 @@ export function AufgabenZeile({
bereiche = [],
mitEinsortieren = false,
mitUebernehmen = false,
+ mitSpiegeln = false,
onBearbeiten,
}: Eigenschaften) {
const router = useRouter()
@@ -198,6 +210,29 @@ export function AufgabenZeile({
+ {mitSpiegeln && (
+
{
+ const daten = new FormData()
+ daten.set('id', aufgabe.id)
+ mitRueckmeldung(() => spiegelnUmschalten(daten))
+ }}
+ >
+
+ Spiegeln
+
+ )}
{onBearbeiten && (
Date: Sat, 12 Sep 2026 04:51:37 +0200
Subject: [PATCH 10/14] chore: Demo-Daten, Bildaufnahme und Platz in der
Kopfleiste
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Die Demo-Instanz redet mit niemandem mehr: Solange DEMO_MODUS gilt,
starten die Jobs fuer Zammad, Telegram und den Spiegel gar nicht erst.
Sie ist mit erfundenen Daten gefuellt und steht nur fuer Bilder — ein
Aufruf nach draussen waere bestenfalls sinnlos.
Die Kopfleiste ist breiter als die Inhaltsspalte. Mit dem elften Eintrag
passte sie dort nicht mehr, und "Einstellungen" verschwand
stillschweigend am rechten Rand. Aufgefallen ist das erst im
aufgenommenen Bild.
Der Kartentext beschrieb noch den Weg von vor dem Kopplungscode.
Co-Authored-By: Claude Opus 5
---
docker-compose.screenshots.yml | 5 +++
prisma/demo-seed.ts | 47 ++++++++++++++++++++++++++++
screenshots/bilder.spec.ts | 1 +
src/app/(app)/integrationen/page.tsx | 7 ++---
src/components/kopfleiste.tsx | 8 ++++-
src/server/jobs.ts | 11 +++++--
6 files changed, 71 insertions(+), 8 deletions(-)
diff --git a/docker-compose.screenshots.yml b/docker-compose.screenshots.yml
index f226003..6f0f2f9 100644
--- a/docker-compose.screenshots.yml
+++ b/docker-compose.screenshots.yml
@@ -68,5 +68,10 @@ services:
# moeglich, hinaus geht trotzdem nichts.
SMTP_HOST: smtp.planer.invalid
SMTP_ABSENDER: Planer
+ # Erfundene Werte, damit die Seite "Integrationen" den eingerichteten
+ # Fall zeigt. Nach draussen geht davon nichts: Die Jobs schweigen,
+ # solange DEMO_MODUS true ist.
+ TELEGRAM_TOKEN: 000000:demo-token-ohne-funktion
+ SPIEGEL_GEHEIMNIS: demo-geheimnis-ohne-funktion
ports:
- '${SCREENSHOT_APP_PORT:-3200}:3000'
diff --git a/prisma/demo-seed.ts b/prisma/demo-seed.ts
index 7e81a9c..4830f74 100644
--- a/prisma/demo-seed.ts
+++ b/prisma/demo-seed.ts
@@ -429,6 +429,7 @@ async function main(): Promise {
anlaesse: 'tagesueberblick,faelligkeit,zeitblock',
perPush: true,
perMail: true,
+ perTelegram: true,
ueberblickUm: 7 * 60,
ruheVon: 20 * 60,
ruheBis: 7 * 60,
@@ -442,6 +443,52 @@ async function main(): Promise {
// nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf
// wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung.
+ // --- Integrationen --------------------------------------------------------
+ await db.integrationszustand.deleteMany({})
+ await db.integrationszustand.create({
+ data: {
+ quelle: 'TELEGRAM',
+ merker: '4711',
+ letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 18)),
+ letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 18)),
+ },
+ })
+ await db.integrationszustand.create({
+ data: {
+ quelle: 'SPIEGEL',
+ letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 15)),
+ letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 15)),
+ },
+ })
+
+ await db.telegramChat.deleteMany({})
+ await db.telegramChat.create({
+ data: {
+ // Erfunden. Der Bot ist ohnehin abgeschaltet, solange DEMO_MODUS gilt.
+ chatId: BigInt(147258369),
+ benutzerId: benutzer.id,
+ bezeichnung: 'Martina',
+ erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 25)),
+ zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 6, 40)),
+ },
+ })
+
+ // Dienstliche Belegung, wie sie aus der Firma hereinkaeme: ohne Titel.
+ await db.spiegelbelegung.deleteMany({})
+ for (const [tag, stunde, dauer] of [
+ [17, 9, 2],
+ [18, 13, 3],
+ [19, 8, 1],
+ ] as const) {
+ await db.spiegelbelegung.create({
+ data: {
+ quelleRef: `demo-spiegel-${tag}-${stunde}`,
+ beginn: new Date(Date.UTC(2026, 2, tag, stunde - 1, 0)),
+ ende: new Date(Date.UTC(2026, 2, tag, stunde - 1 + dauer, 0)),
+ },
+ })
+ }
+
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
}
diff --git a/screenshots/bilder.spec.ts b/screenshots/bilder.spec.ts
index 15f69a2..da824ef 100644
--- a/screenshots/bilder.spec.ts
+++ b/screenshots/bilder.spec.ts
@@ -43,6 +43,7 @@ const AUFNAHMEN: Aufnahme[] = [
{ name: 'ideen', pfad: '/ideen' },
{ name: 'bereiche', pfad: '/bereiche' },
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
+ { name: 'integrationen', pfad: '/integrationen' },
{ name: 'einstellungen', pfad: '/einstellungen' },
{
name: 'benachrichtigungen',
diff --git a/src/app/(app)/integrationen/page.tsx b/src/app/(app)/integrationen/page.tsx
index c768703..684f798 100644
--- a/src/app/(app)/integrationen/page.tsx
+++ b/src/app/(app)/integrationen/page.tsx
@@ -166,10 +166,9 @@ export default async function IntegrationenSeite() {
Telegram-Chats
Nur wer hier steht, darf mit dem Bot reden — und nur diese Chats bekommen
- Meldungen. Alles andere wird verworfen, ohne zu antworten. Die Kennung
- deines Chats verrät dir der Bot, wenn du ihm{' '}
- /start schickst und danach hier nachsiehst;
- steht sie noch nicht drin, findest du sie im Protokoll.
+ Meldungen. Alles andere wird verworfen, ohne zu antworten. Zum Verbinden
+ holst du dir hier einen Code und schickst ihn dem Bot; er gilt zehn Minuten
+ und genau einmal.
diff --git a/src/components/kopfleiste.tsx b/src/components/kopfleiste.tsx
index f314be0..e4f6ba0 100644
--- a/src/components/kopfleiste.tsx
+++ b/src/components/kopfleiste.tsx
@@ -50,7 +50,13 @@ export function Kopfleiste({
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
die zweite Zeile zu drängen.
*/}
-
+ {/*
+ Breiter als die Inhaltsspalte (max-w-5xl): Mit dem elften Eintrag
+ passte die Leiste dort nicht mehr, und "Einstellungen" verschwand
+ stillschweigend am rechten Rand. Eine Leiste, die Punkte verschluckt,
+ ist schlimmer als eine, die nicht buendig mit dem Text steht.
+ */}
+
Planer
diff --git a/src/server/jobs.ts b/src/server/jobs.ts
index 93196a5..7aaa372 100644
--- a/src/server/jobs.ts
+++ b/src/server/jobs.ts
@@ -111,7 +111,12 @@ export function jobsStarten(): void {
const einst = umgebung()
- if (einst.zammadAktiv) {
+ // Die Demo-Instanz redet mit niemandem. Sie ist mit erfundenen Daten
+ // gefuellt und steht nur fuer Bilder – ein Aufruf nach draussen waere
+ // bestenfalls sinnlos und schlimmstenfalls peinlich.
+ const nachDraussen = !einst.DEMO_MODUS
+
+ if (einst.zammadAktiv && nachDraussen) {
cron.schedule(ZAMMAD_PLAN, () => {
void zammadAbgleichen(DIENST)
.then((bericht) => {
@@ -127,7 +132,7 @@ export function jobsStarten(): void {
})
}
- if (einst.telegramAktiv) {
+ if (einst.telegramAktiv && nachDraussen) {
cron.schedule(TELEGRAM_PLAN, () => {
void telegramAbholen(DIENST)
.then((bericht) => {
@@ -142,7 +147,7 @@ export function jobsStarten(): void {
})
}
- if (einst.spiegelSendenAktiv) {
+ if (einst.spiegelSendenAktiv && nachDraussen) {
cron.schedule(SPIEGEL_PLAN, () => {
void spiegelSenden().catch((fehler: unknown) => {
console.error('Spiegel-Versand abgebrochen:', fehler)
From 475b1d5c08e82518e9cab40c41505ebd217b49ae Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Sat, 12 Sep 2026 04:51:38 +0200
Subject: [PATCH 11/14] docs: Handbuchseite, README und Fahrplan fuer M6
Der Wartungsplaner steht als offener Punkt drin, nicht als Haken.
Co-Authored-By: Claude Opus 5
---
CHANGELOG.md | 41 +++++++++
README.md | 49 ++++++++++-
ROADMAP.md | 25 ++++--
docs/handbuch/README.md | 9 +-
docs/handbuch/integrationen.md | 151 +++++++++++++++++++++++++++++++++
5 files changed, 263 insertions(+), 12 deletions(-)
create mode 100644 docs/handbuch/integrationen.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73f382a..a4b325d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,47 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
+### Hinzugefügt
+
+- **Zammad** (nur `firma`): Tickets, auf die der Suchausdruck passt, werden zu
+ Aufgaben — mit Verweis ins Ticket. Wird ein Ticket dort geschlossen, hakt
+ der Planer die Aufgabe ab.
+- **Telegram** (nur `daheim`): Eine Nachricht an den Bot wird zur Aufgabe.
+ Meldungen gehen denselben Weg zurück, mit einem Knopf zum Abhaken. Ein Chat
+ meldet sich über einen Einmalcode an, der zehn Minuten gilt.
+- **Telegram als dritter Meldeweg** neben Push und E-Mail.
+- **Spiegel** zwischen den Instanzen: `firma` schickt Belegungen ohne Titel
+ und die einzeln dafür markierten Aufgaben, `daheim` empfängt. Gespiegelte
+ Belegung erscheint in der Wochenansicht als „Dienstlich belegt" und zählt
+ gegen die Tageskapazität.
+- Seite **Integrationen**: je Integration Zustand, letzter Erfolg, letzter
+ Fehler und ein Knopf, der einen Lauf sofort anstößt.
+- Handbuchseite zu den Integrationen.
+
+### Geändert
+
+- Was aus einem Fund wird, entscheidet für alle Integrationen dieselbe
+ Stelle. Geführt werden nur Titel, Notiz, Verweis und Fälligkeit — Bereich,
+ Projekt, Priorität und geschätzter Aufwand gehören der Planung und werden
+ nie von außen überschrieben.
+- Eine klemmende Integration meldet sich wie ein klemmender Kalender: erst
+ nach dem dritten Fehlversuch in Folge.
+- Die Demo-Instanz für die Bilder spricht nicht mehr nach außen.
+
+### Sicherheit
+
+- Wer dem Telegram-Bot schreibt, ohne verbunden zu sein, bekommt **keine
+ Antwort** — auch keine Fehlermeldung. Sonst ließe sich herausfinden, dass
+ es diesen Planer gibt.
+- Jede Sendung des Spiegels trägt eine Signatur über den Rumpf und ist nach
+ fünf Minuten wertlos. Der Empfänger antwortet auf jeden Fehlschlag gleich:
+ falsches Geheimnis, verstellte Uhr und Unsinn im Inhalt sind von außen
+ nicht zu unterscheiden.
+- Der Signaturvergleich läuft in gleichbleibender Zeit.
+- Aufgaben verlassen die Instanz nur mit ausdrücklicher Freigabe je Aufgabe.
+ Ein Schalter für einen ganzen Bereich würde zu leicht Kundennamen nach
+ Hause schicken.
+
## [0.5.0] – 2026-09-11
### Hinzugefügt
diff --git a/README.md b/README.md
index 8fe37b7..8056d0e 100644
--- a/README.md
+++ b/README.md
@@ -33,8 +33,12 @@ wird.
Bereich, wie voll der heutige Tag schon ist
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
Kalenderterminen, Verschiebungen im Kalender kommen zurück
-- **Benachrichtigungen** per Web-Push und E-Mail: Tagesüberblick, fällige
- Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk still
+- **Benachrichtigungen** per Web-Push, E-Mail und Telegram: Tagesüberblick,
+ fällige Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk
+ still
+- **Integrationen**: Zammad-Tickets werden zu Aufgaben, Telegram erfasst und
+ hakt ab, und der Spiegel bringt dienstliche Belegung nach Hause — ohne
+ Titel
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
@@ -45,7 +49,7 @@ wird.
### Geplant
-Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
+Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server.
Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie
@@ -63,6 +67,7 @@ Siehe [ROADMAP.md](ROADMAP.md).
| Bereiche |  |  |
| Einstellungen |  |  |
| Meldungen |  |  |
+| Integrationen |  |  |
| Protokoll |  |  |
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
@@ -171,6 +176,44 @@ Klartextadresse nicht.
Was der Planer wann meldet, steht im
[Handbuch](docs/handbuch/benachrichtigungen.md).
+### Integrationen
+
+Welche Integration eine Instanz überhaupt darf, entscheidet `INSTANZ` — nicht
+die Konfiguration. Ein Zammad-Token in der Instanz `daheim` bleibt wirkungslos.
+
+| Variable | Standard | Bedeutung |
+| ------------------- | ------------------- | ---------------------------------------------------------------- |
+| `ZAMMAD_URL` | _(leer)_ | Adresse der Zammad-Instanz, nur `firma` |
+| `ZAMMAD_TOKEN` | _(leer)_ | Token aus **Profil → Token-Zugriff** |
+| `ZAMMAD_ABFRAGE` | `owner_id:me AND …` | Suchausdruck; was darauf passt, wird zur Aufgabe |
+| `TELEGRAM_TOKEN` | _(leer)_ | Bot-Token von [@BotFather](https://t.me/BotFather), nur `daheim` |
+| `SPIEGEL_GEHEIMNIS` | _(leer)_ | Gemeinsames Geheimnis beider Instanzen |
+| `SPIEGEL_ZIEL` | _(leer)_ | Adresse der empfangenden Instanz; nur auf der sendenden Seite |
+
+**Zammad** liest nur. Ein Ticket schließt man im Ticketsystem — sonst weiß die
+Kollegin nicht, warum es plötzlich zu ist. Umgekehrt schließt der Planer eine
+Aufgabe, sobald Zammad das Ticket als erledigt meldet.
+
+**Telegram** braucht keinen Webhook und damit keine von außen erreichbare
+Adresse. Welche Chats mitreden dürfen, wird in der Anwendung unter
+**Integrationen** gekoppelt: Knopf drücken, Code an den Bot schicken, fertig.
+Nachrichten aus unbekannten Chats werden verworfen, ohne zu antworten.
+
+**Der Spiegel** geht in eine Richtung: `firma` sendet, `daheim` empfängt.
+Hinüber gehen Belegungen **ohne Titel** — daheim ist zu sehen, _dass_ die Zeit
+weg ist, nicht woran gearbeitet wird — und jene Aufgaben, die einzeln zum
+Spiegeln markiert wurden. Jede Sendung ist mit dem gemeinsamen Geheimnis
+signiert und älter als fünf Minuten wertlos.
+
+Das Geheimnis erzeugst du wie das Sitzungsgeheimnis:
+
+```bash
+openssl rand -base64 32
+```
+
+Was der Alltag damit anfängt, steht im
+[Handbuch](docs/handbuch/integrationen.md).
+
### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung |
diff --git a/ROADMAP.md b/ROADMAP.md
index 05b5db6..4b4f711 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -10,7 +10,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
-| [M6](#m6--integrationen) | Integrationen | geplant |
+| [M6](#m6--integrationen) | Integrationen | erledigt |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
| [M8](#m8--mcp) | MCP | geplant |
@@ -112,10 +112,25 @@ Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem.
## M6 – Integrationen
-- [ ] Zammad und Wartungsplaner-API (nur `firma`)
-- [ ] Telegram (nur `daheim`)
-- [ ] Spiegel: `firma` sendet, `daheim` empfängt
-- [ ] Jede Integration upsertet über `[quelle, quelleRef]`
+**Erledigt am 12.09.2026 mit Version 0.6.0.**
+
+**Ziel:** Aufgaben dort abholen, wo sie entstehen — ohne dass die Planung
+darunter leidet, die hier passiert.
+
+- [x] Gemeinsame Aufnahmestelle: jede Integration liefert nur Funde
+- [x] Zammad (nur `firma`), lesend
+- [ ] **Wartungsplaner-API** — Schnittstelle steht noch nicht fest
+- [x] Telegram (nur `daheim`): erfassen, melden, abhaken
+- [x] Spiegel: `firma` sendet, `daheim` empfängt
+- [x] Jede Integration upsertet über `[quelle, quelleRef]`
+
+**Offen geblieben:** Der **Wartungsplaner** ist nicht angebunden. Die
+Aufnahmestelle steht bereit und die Seite „Integrationen" führt ihn als
+sichtbare Lücke; es fehlt allein das Abholen, und dafür muss erst feststehen,
+was das Produkt an Schnittstelle hergibt.
+
+**Bewusst offen gelassen:** Zammad wird nur gelesen. Ein Ticket schließt man
+im Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
## M7 – Schnellerfassung und API
diff --git a/docs/handbuch/README.md b/docs/handbuch/README.md
index f3b7b5c..ba80942 100644
--- a/docs/handbuch/README.md
+++ b/docs/handbuch/README.md
@@ -20,12 +20,14 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
verbinden, Belegung verstehen
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen
-8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail
- einrichten, Ruhezeiten setzen
+8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
+ Telegram einrichten, Ruhezeiten setzen
+9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
+ zwischen Firma und Daheim
## Verwaltung
-9. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
+10. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt
@@ -33,7 +35,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein |
| ------------------------------- | ----------- |
-| Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 |
| MCP | M8 |
diff --git a/docs/handbuch/integrationen.md b/docs/handbuch/integrationen.md
new file mode 100644
index 0000000..e2f0624
--- /dev/null
+++ b/docs/handbuch/integrationen.md
@@ -0,0 +1,151 @@
+# Integrationen
+
+Was von außen hereinkommt — und was diese Instanz hinausgibt. Du findest den
+Überblick im Menü unter **Integrationen**.
+
+
+
+Welche Integration es überhaupt gibt, hängt an der Instanz und ist nicht
+verhandelbar:
+
+| | Firma | Daheim |
+| -------------- | ------ | -------- |
+| Zammad | ja | nein |
+| Wartungsplaner | ja | nein |
+| Telegram | nein | ja |
+| Spiegel | sendet | empfängt |
+
+Eine Integration, die deine Instanz nicht vorsieht, lässt sich auch mit
+vollständiger Konfiguration nicht anschalten. Das steckt in der Anwendung,
+nicht in einer Einstellung, die sich versehentlich verstellen ließe.
+
+## Was jede Integration mit deinen Aufgaben darf
+
+Für alle gilt dieselbe Regel — und die ist wichtiger als jedes Detail
+darunter:
+
+| Führt das Ursprungssystem | Führst du |
+| --------------------------------- | ------------------------------------------------ |
+| Titel, Notiz, Verweis, Fälligkeit | Bereich, Projekt, Priorität, geschätzter Aufwand |
+
+Wer ein Ticket in einen Bereich einsortiert und den Aufwand schätzt, muss
+sich darauf verlassen können, dass der nächste Abgleich das nicht wegräumt.
+Umgekehrt: Ändert sich drüben der Titel, zieht er hier mit.
+
+Dieselbe Sache ergibt nie eine zweite Aufgabe. Der Planer merkt sich zu jeder
+Aufgabe, woher sie kam und unter welcher Kennung.
+
+## Zammad
+
+Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Voreingestellt
+sind die dir zugewiesenen offenen Tickets; der Ausdruck steht in der
+Konfiguration (`ZAMMAD_ABFRAGE`) und versteht Zammads Suchsyntax.
+
+- Der Titel wird `#Nummer Betreff`, der Verweis führt direkt ins Ticket.
+- Ein Ticket, das Zammad als geschlossen meldet, hakt der Planer ab.
+- **Geschrieben wird nach Zammad nichts.** Ein Ticket schließt man im
+ Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
+
+Der Token gehört einem Benutzer und sieht genau das, was dieser Benutzer
+sehen darf. Der Planer umgeht keine Rechte.
+
+## Wartungsplaner
+
+Noch nicht angebunden. Die Aufnahmestelle steht bereit — es fehlt allein das
+Abholen, und dafür muss die Schnittstelle feststehen. Auf der Seite
+**Integrationen** ist der Punkt als offene Lücke sichtbar, damit er nicht in
+Vergessenheit gerät.
+
+## Telegram
+
+Drei Dinge in einem Kanal.
+
+### Chat verbinden
+
+1. Unter **Integrationen** auf **Chat verbinden**.
+2. Der achtstellige Code erscheint. Er gilt **zehn Minuten** und genau
+ **einmal**.
+3. Schick ihn dem Bot — als Nachricht oder als `/start ABCD2345`.
+4. Der Bot bestätigt, und der Chat erscheint in der Liste.
+
+> **Warum der Umweg?** Wer nicht in der Liste steht, bekommt vom Bot keine
+> Antwort — auch keine Fehlermeldung. Sonst könnte jeder, der die Kennung des
+> Bots kennt, herausfinden, dass es dich gibt. Der Code ist der Beweis, dass
+> jemand mit Zugang zur Anwendung diesen Chat wirklich will.
+
+### Erfassen
+
+Jede Nachricht an den Bot wird eine Aufgabe. Lange Texte werden gekürzt; der
+Rest landet in der Notiz und geht nicht verloren. Die Aufgabe hat keinen
+Bereich — sie liegt also in der **Inbox** und will einsortiert werden.
+
+### Melden und abhaken
+
+Ist Telegram in den [Benachrichtigungen](benachrichtigungen.md) als Weg
+eingeschaltet, kommen Meldungen auch hier an. Geht es um genau eine Aufgabe,
+hängt darunter ein Knopf **✓ Erledigt** — ein Druck, und sie ist abgehakt,
+ohne die Anwendung zu öffnen.
+
+Mehr kann der Knopf nicht. Löschen oder Verschieben aus einem Chat heraus
+wäre zu leicht versehentlich getroffen.
+
+Je Chat lässt sich einzeln einstellen, ob er Meldungen bekommt: Das Glöckchen
+in der Liste schaltet um.
+
+## Der Spiegel
+
+Die Firma schickt nach Hause, nicht umgekehrt. Zwei Dinge gehen hinüber.
+
+**Belegungen.** Wann dienstlich Zeit verplant ist — ohne Titel, ohne Aufgabe,
+ohne Kunde. Daheim erscheinen sie in der Wochenansicht als gestreifte Balken
+mit der Aufschrift „Dienstlich belegt" und zählen gegen die Tageskapazität.
+So kollidiert die private Planung nicht mit der dienstlichen, und trotzdem
+verlässt kein Firmeninhalt die Firma.
+
+**Markierte Aufgaben.** Nur was du an der Aufgabe ausdrücklich zum Spiegeln
+freigibst, geht mit Titel hinüber. Das ist eine Einzelentscheidung je
+Aufgabe, kein Schalter für alles: Wer versehentlich einen ganzen Bereich
+spiegelt, schickt Kundennamen nach Hause.
+
+Was der Sender nicht mehr schickt, verschwindet auch drüben. Wird ein
+Zeitblock in der Firma ausgeplant, ist die Sperre daheim beim nächsten Lauf
+weg — sonst stünde dort für immer eine Blockade, die es längst nicht mehr
+gibt.
+
+## Wann läuft was
+
+| Integration | Takt |
+| ----------- | --------------- |
+| Telegram | alle 2 Minuten |
+| Zammad | alle 15 Minuten |
+| Spiegel | alle 15 Minuten |
+
+Wer dem Bot etwas schickt, erwartet die Aufgabe gleich in der Liste. In
+Zammad ändert sich dagegen nichts im Minutentakt.
+
+Warten muss man trotzdem nicht: Auf der Seite **Integrationen** stößt
+**Jetzt abrufen** einen Lauf von Hand an. Das lohnt vor allem direkt nach dem
+Einrichten — sonst wüsstest du erst beim nächsten Takt, ob die Zugangsdaten
+stimmen.
+
+## Wenn etwas klemmt
+
+Unter jeder Integration steht, wann sie zuletzt geglückt ist und woran sie
+zuletzt gescheitert ist. Nach dem dritten Fehlschlag hintereinander gilt sie
+als **gestört** — und dann meldet sich der Planer auch, sofern du den Anlass
+„Ein Kalender lässt sich nicht abgleichen" eingeschaltet hast.
+
+Erst nach dem dritten Mal: Ein Netz hustet, ein Anbieter hat Wartung. Beim
+ersten Fehlschlag zu melden hieße, dich für Dinge zu wecken, die sich von
+selbst erledigen.
+
+| Symptom | Ursache |
+| ---------------------------------------- | --------------------------------------------------------------------------------- |
+| „Nicht eingerichtet" trotz Konfiguration | Die Instanz sieht diese Integration nicht vor |
+| Zammad findet nichts | Der Suchausdruck passt auf kein Ticket — in Zammad selbst probieren |
+| Der Bot antwortet nicht | Der Chat ist nicht verbunden, oder der Code war abgelaufen |
+| Der Spiegel wird abgelehnt | Verschiedene Geheimnisse, oder die Uhren laufen mehr als fünf Minuten auseinander |
+
+---
+
+Zurück zum [Handbuch](README.md).
From 98d170b68a626b3051b8692a87884bb8bfbb4d3e Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Sat, 12 Sep 2026 09:00:49 +0200
Subject: [PATCH 12/14] docs: Handbuchbilder fuer M6 neu aufnehmen
Der Hinweis unter dem Wochenraster erklaerte nur die grauen Balken; die
gestreiften aus der anderen Instanz fehlten darin.
Co-Authored-By: Claude Opus 5
---
docs/bilder/anmelden-dunkel-handy.png | Bin 33948 -> 34006 bytes
docs/bilder/anmelden-dunkel-schreibtisch.png | Bin 46916 -> 46987 bytes
docs/bilder/anmelden-hell-handy.png | Bin 34772 -> 34883 bytes
docs/bilder/anmelden-hell-schreibtisch.png | Bin 47993 -> 48069 bytes
docs/bilder/aufgaben-dunkel-handy.png | Bin 273724 -> 273776 bytes
docs/bilder/aufgaben-dunkel-schreibtisch.png | Bin 288606 -> 288693 bytes
docs/bilder/aufgaben-hell-handy.png | Bin 279019 -> 279076 bytes
docs/bilder/aufgaben-hell-schreibtisch.png | Bin 294973 -> 295540 bytes
.../benachrichtigungen-dunkel-handy.png | Bin 171559 -> 180059 bytes
...benachrichtigungen-dunkel-schreibtisch.png | Bin 159706 -> 168207 bytes
docs/bilder/benachrichtigungen-hell-handy.png | Bin 175834 -> 184678 bytes
.../benachrichtigungen-hell-schreibtisch.png | Bin 163459 -> 172298 bytes
docs/bilder/bereiche-dunkel-handy.png | Bin 222523 -> 222579 bytes
docs/bilder/bereiche-dunkel-schreibtisch.png | Bin 240169 -> 240300 bytes
docs/bilder/bereiche-hell-handy.png | Bin 226509 -> 226571 bytes
docs/bilder/bereiche-hell-schreibtisch.png | Bin 244193 -> 244826 bytes
docs/bilder/einstellungen-dunkel-handy.png | Bin 276215 -> 284493 bytes
.../einstellungen-dunkel-schreibtisch.png | Bin 288556 -> 297558 bytes
docs/bilder/einstellungen-hell-handy.png | Bin 284006 -> 292991 bytes
.../einstellungen-hell-schreibtisch.png | Bin 295682 -> 305250 bytes
docs/bilder/ideen-dunkel-handy.png | Bin 113218 -> 113279 bytes
docs/bilder/ideen-dunkel-schreibtisch.png | Bin 134411 -> 134969 bytes
docs/bilder/ideen-hell-handy.png | Bin 115337 -> 115405 bytes
docs/bilder/ideen-hell-schreibtisch.png | Bin 136587 -> 137584 bytes
docs/bilder/inbox-dunkel-handy.png | Bin 103806 -> 103865 bytes
docs/bilder/inbox-dunkel-schreibtisch.png | Bin 124221 -> 124412 bytes
docs/bilder/inbox-hell-handy.png | Bin 105790 -> 105851 bytes
docs/bilder/inbox-hell-schreibtisch.png | Bin 126471 -> 127131 bytes
docs/bilder/integrationen-dunkel-handy.png | Bin 0 -> 208771 bytes
.../integrationen-dunkel-schreibtisch.png | Bin 0 -> 218315 bytes
docs/bilder/integrationen-hell-handy.png | Bin 0 -> 215062 bytes
.../integrationen-hell-schreibtisch.png | Bin 0 -> 224478 bytes
docs/bilder/kalenderkonten-dunkel-handy.png | Bin 138599 -> 138665 bytes
.../kalenderkonten-dunkel-schreibtisch.png | Bin 155868 -> 155232 bytes
docs/bilder/kalenderkonten-hell-handy.png | Bin 142314 -> 142396 bytes
.../kalenderkonten-hell-schreibtisch.png | Bin 158725 -> 158799 bytes
docs/bilder/projekte-dunkel-handy.png | Bin 69697 -> 69763 bytes
docs/bilder/projekte-dunkel-schreibtisch.png | Bin 100096 -> 100089 bytes
docs/bilder/projekte-hell-handy.png | Bin 70969 -> 71036 bytes
docs/bilder/projekte-hell-schreibtisch.png | Bin 101443 -> 101974 bytes
docs/bilder/protokoll-dunkel-handy.png | Bin 71686 -> 71758 bytes
docs/bilder/protokoll-dunkel-schreibtisch.png | Bin 120778 -> 122220 bytes
docs/bilder/protokoll-hell-handy.png | Bin 70751 -> 70828 bytes
docs/bilder/protokoll-hell-schreibtisch.png | Bin 123109 -> 124898 bytes
docs/bilder/start-dunkel-handy.png | Bin 233538 -> 233598 bytes
docs/bilder/start-dunkel-schreibtisch.png | Bin 242122 -> 242687 bytes
docs/bilder/start-hell-handy.png | Bin 239405 -> 239468 bytes
docs/bilder/start-hell-schreibtisch.png | Bin 247639 -> 248710 bytes
docs/bilder/woche-dunkel-handy.png | Bin 275422 -> 292973 bytes
docs/bilder/woche-dunkel-schreibtisch.png | Bin 307943 -> 357746 bytes
docs/bilder/woche-hell-handy.png | Bin 274329 -> 290568 bytes
docs/bilder/woche-hell-schreibtisch.png | Bin 307928 -> 357735 bytes
src/app/(app)/woche/page.tsx | 3 ++-
53 files changed, 2 insertions(+), 1 deletion(-)
create mode 100644 docs/bilder/integrationen-dunkel-handy.png
create mode 100644 docs/bilder/integrationen-dunkel-schreibtisch.png
create mode 100644 docs/bilder/integrationen-hell-handy.png
create mode 100644 docs/bilder/integrationen-hell-schreibtisch.png
diff --git a/docs/bilder/anmelden-dunkel-handy.png b/docs/bilder/anmelden-dunkel-handy.png
index d92cf0750e8858444c77661bd2d44633c1d53a09..4b0ee00b7fab2cc15c26d279c7235df7bdc7e64f 100644
GIT binary patch
literal 34006
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