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({ )} + +
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'} +

    +
    + + + + +
  • + ))} +
+ )} + + {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. +

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