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.',
+ }
+ }
+}