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), } }