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