feat: Integrationen sichtbar und bedienbar machen
Eine Seite zeigt je Integration, ob sie vorgesehen, eingerichtet oder gestoert ist, wann sie zuletzt geglueckt ist und woran sie klemmt. Der Wartungsplaner steht sichtbar als offene Luecke darin, nicht als Leerstelle. Telegram-Chats werden hier gekoppelt und wieder getrennt, Meldungen je Chat einzeln geschaltet. Aufgaben bekommen einen Schalter zum Spiegeln — einzeln und je Aufgabe, denn wer versehentlich einen ganzen Bereich spiegelt, schickt Kundennamen nach Hause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bc60e5f864
commit
1281c3ea96
@@ -34,6 +34,7 @@ export type EinstellungAnzeige = {
|
|||||||
anlaesse: Anlass[]
|
anlaesse: Anlass[]
|
||||||
perPush: boolean
|
perPush: boolean
|
||||||
perMail: boolean
|
perMail: boolean
|
||||||
|
perTelegram: boolean
|
||||||
ueberblickUm: string
|
ueberblickUm: string
|
||||||
ruheVon: string
|
ruheVon: string
|
||||||
ruheBis: string
|
ruheBis: string
|
||||||
@@ -46,6 +47,9 @@ type Eigenschaften = {
|
|||||||
geraete: GeraetAnzeige[]
|
geraete: GeraetAnzeige[]
|
||||||
pushMoeglich: boolean
|
pushMoeglich: boolean
|
||||||
mailMoeglich: boolean
|
mailMoeglich: boolean
|
||||||
|
telegramMoeglich: boolean
|
||||||
|
/** Wie viele Chats Meldungen empfangen. Null heisst: noch keiner. */
|
||||||
|
telegramChats: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +72,8 @@ export function Benachrichtigungen({
|
|||||||
geraete,
|
geraete,
|
||||||
pushMoeglich,
|
pushMoeglich,
|
||||||
mailMoeglich,
|
mailMoeglich,
|
||||||
|
telegramMoeglich,
|
||||||
|
telegramChats,
|
||||||
}: Eigenschaften) {
|
}: Eigenschaften) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [laeuft, starte] = useTransition()
|
const [laeuft, starte] = useTransition()
|
||||||
@@ -311,6 +317,35 @@ export function Benachrichtigungen({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perTelegram"
|
||||||
|
defaultChecked={einstellung.perTelegram}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
Telegram
|
||||||
|
{!telegramMoeglich ? (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
) : telegramChats === 0 ? (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— noch kein Chat eingetragen
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— an {telegramChats} Chat{telegramChats === 1 ? '' : 's'}, mit Knopf zum
|
||||||
|
Abhaken
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ export default async function EinstellungenSeite() {
|
|||||||
|
|
||||||
const einst = umgebung()
|
const einst = umgebung()
|
||||||
|
|
||||||
const [benachrichtigung, pushGeraete] = await Promise.all([
|
const [benachrichtigung, telegramChats, pushGeraete] = await Promise.all([
|
||||||
db.benachrichtigungseinstellung.findUnique({
|
db.benachrichtigungseinstellung.findUnique({
|
||||||
where: { benutzerId: benutzer.id },
|
where: { benutzerId: benutzer.id },
|
||||||
select: {
|
select: {
|
||||||
anlaesse: true,
|
anlaesse: true,
|
||||||
perPush: true,
|
perPush: true,
|
||||||
perMail: true,
|
perMail: true,
|
||||||
|
perTelegram: true,
|
||||||
ueberblickUm: true,
|
ueberblickUm: true,
|
||||||
ruheVon: true,
|
ruheVon: true,
|
||||||
ruheBis: true,
|
ruheBis: true,
|
||||||
@@ -37,6 +38,7 @@ export default async function EinstellungenSeite() {
|
|||||||
zeitblockVorlaufMinuten: true,
|
zeitblockVorlaufMinuten: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
db.telegramChat.count({ where: { benutzerId: benutzer.id, meldungen: true } }),
|
||||||
db.pushGeraet.findMany({
|
db.pushGeraet.findMany({
|
||||||
where: { benutzerId: benutzer.id },
|
where: { benutzerId: benutzer.id },
|
||||||
orderBy: { erstelltAm: 'asc' },
|
orderBy: { erstelltAm: 'asc' },
|
||||||
@@ -119,10 +121,13 @@ export default async function EinstellungenSeite() {
|
|||||||
<Benachrichtigungen
|
<Benachrichtigungen
|
||||||
pushMoeglich={einst.pushAktiv}
|
pushMoeglich={einst.pushAktiv}
|
||||||
mailMoeglich={einst.mailAktiv}
|
mailMoeglich={einst.mailAktiv}
|
||||||
|
telegramMoeglich={einst.telegramAktiv}
|
||||||
|
telegramChats={telegramChats}
|
||||||
einstellung={{
|
einstellung={{
|
||||||
anlaesse: [...einstellung.anlaesse],
|
anlaesse: [...einstellung.anlaesse],
|
||||||
perPush: einstellung.perPush,
|
perPush: einstellung.perPush,
|
||||||
perMail: einstellung.perMail,
|
perMail: einstellung.perMail,
|
||||||
|
perTelegram: einstellung.perTelegram,
|
||||||
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
|
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
|
||||||
ruheVon: alsUhrzeit(einstellung.ruheVon),
|
ruheVon: alsUhrzeit(einstellung.ruheVon),
|
||||||
ruheBis: alsUhrzeit(einstellung.ruheBis),
|
ruheBis: alsUhrzeit(einstellung.ruheBis),
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Plug } from 'lucide-react'
|
||||||
|
import { TelegramChats } from '@/app/(app)/integrationen/telegram-chats'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBeschreibung,
|
||||||
|
CardInhalt,
|
||||||
|
CardKopf,
|
||||||
|
CardTitel,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import type { Quelle } from '@/generated/prisma/enums'
|
||||||
|
import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration'
|
||||||
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Integrationen' }
|
||||||
|
|
||||||
|
type Zeile = {
|
||||||
|
quelle: Quelle
|
||||||
|
name: string
|
||||||
|
beschreibung: string
|
||||||
|
/** In dieser Instanz überhaupt vorgesehen? */
|
||||||
|
vorgesehen: boolean
|
||||||
|
/** Und auch eingerichtet? */
|
||||||
|
aktiv: boolean
|
||||||
|
hinweis: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function IntegrationenSeite() {
|
||||||
|
await verlangeAnmeldung('/integrationen')
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
const [zustaende, chats] = await Promise.all([
|
||||||
|
db.integrationszustand.findMany(),
|
||||||
|
db.telegramChat.findMany({
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
chatId: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
meldungen: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutzt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const nachQuelle = new Map(zustaende.map((z) => [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 (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Plug className="text-gedaempft-vordergrund size-6" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Integrationen</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Was von außen hereinkommt — und was diese Instanz hinausgibt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{zeilen.map((zeile) => {
|
||||||
|
const zustand = nachQuelle.get(zeile.quelle)
|
||||||
|
const gestoert = (zustand?.fehlversuche ?? 0) >= INTEGRATION_FEHLER_AB_VERSUCH
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={zeile.quelle} className={zeile.vorgesehen ? '' : 'opacity-60'}>
|
||||||
|
<CardKopf>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CardTitel className="text-base">{zeile.name}</CardTitel>
|
||||||
|
{!zeile.vorgesehen ? (
|
||||||
|
<Kennzeichen art="neutral">
|
||||||
|
In dieser Instanz nicht vorgesehen
|
||||||
|
</Kennzeichen>
|
||||||
|
) : gestoert ? (
|
||||||
|
<Kennzeichen art="gefahr">Gestört</Kennzeichen>
|
||||||
|
) : zeile.aktiv ? (
|
||||||
|
<Kennzeichen art="erfolg">Läuft</Kennzeichen>
|
||||||
|
) : (
|
||||||
|
<Kennzeichen art="warnung">Nicht eingerichtet</Kennzeichen>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardBeschreibung>{zeile.beschreibung}</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
|
||||||
|
{zeile.vorgesehen && !zeile.aktiv && <p>{zeile.hinweis}</p>}
|
||||||
|
|
||||||
|
{zustand?.letzterErfolg && (
|
||||||
|
<p>Zuletzt geglückt: {alsDatumUhrzeit(zustand.letzterErfolg)}</p>
|
||||||
|
)}
|
||||||
|
{zustand?.letzterFehler && (
|
||||||
|
<p className="text-gefahr">
|
||||||
|
Letzter Fehler: {zustand.letzterFehler}
|
||||||
|
{zustand.fehlversuche > 1 && ` (${zustand.fehlversuche}× in Folge)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!zustand && zeile.aktiv && <p>Noch kein Lauf.</p>}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{einst.telegramAktiv && (
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Telegram-Chats</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
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{' '}
|
||||||
|
<code className="text-xs">/start</code> schickst und danach hier nachsiehst;
|
||||||
|
steht sie noch nicht drin, findest du sie im Protokoll.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<TelegramChats
|
||||||
|
chats={chats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
chatId: c.chatId.toString(),
|
||||||
|
bezeichnung: c.bezeichnung,
|
||||||
|
meldungen: c.meldungen,
|
||||||
|
erstelltAm: alsDatumUhrzeit(c.erstelltAm),
|
||||||
|
zuletztBenutzt: c.zuletztBenutzt
|
||||||
|
? alsDatumUhrzeit(c.zuletztBenutzt)
|
||||||
|
: null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{chats.length === 0 ? (
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Noch kein Chat verbunden. Ohne Verbindung nimmt der Bot nichts an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{chats.map((chat) => (
|
||||||
|
<li key={chat.id} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{chat.bezeichnung ?? `Chat ${chat.chatId}`}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Verbunden {chat.erstelltAm}
|
||||||
|
{chat.zuletztBenutzt
|
||||||
|
? ` · zuletzt benutzt ${chat.zuletztBenutzt}`
|
||||||
|
: ' · noch nichts geschickt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={
|
||||||
|
chat.meldungen ? 'Keine Meldungen mehr hierher' : 'Meldungen hierher'
|
||||||
|
}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', chat.id)
|
||||||
|
mitRueckmeldung(() => chatMeldungenUmschalten(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chat.meldungen ? (
|
||||||
|
<Bell className="text-erfolg" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<BellOff aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Meldungen umschalten</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title="Chat trennen"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', chat.id)
|
||||||
|
mitRueckmeldung(() => chatEntfernen(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Trennen</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{code ? (
|
||||||
|
<div className="border-rand rounded-md border p-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
Schick dem Bot jetzt diesen Code — als Nachricht oder als{' '}
|
||||||
|
<code className="text-xs">/start {code.code}</code>:
|
||||||
|
</p>
|
||||||
|
<p className="my-2 font-mono text-2xl tracking-widest">{code.code}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Gültig für {code.minuten} Minuten und nur ein einziges Mal. Danach erscheint
|
||||||
|
der Chat hier — spätestens nach dem nächsten Abholen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Button art="umriss" disabled={laeuft} onClick={anfordern}>
|
||||||
|
{laeuft ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Link2 aria-hidden />
|
||||||
|
)}
|
||||||
|
Chat verbinden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ export function Kopfleiste({
|
|||||||
{ pfad: '/ideen', name: 'Ideen' },
|
{ pfad: '/ideen', name: 'Ideen' },
|
||||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||||
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||||
|
{ pfad: '/integrationen', name: 'Integrationen' },
|
||||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { auditSchreiben } from '@/server/audit'
|
import { auditSchreiben } from '@/server/audit'
|
||||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -347,6 +348,54 @@ export async function einsortieren(daten: FormData): Promise<Rueckmeldung> {
|
|||||||
return { ok: true, meldung: `„${aufgabe.titel}" liegt jetzt in ${bereich.name}.` }
|
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<Rueckmeldung> {
|
||||||
|
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. */
|
/** Aus einer Idee wird eine Aufgabe. */
|
||||||
export async function ideeUebernehmen(daten: FormData): Promise<Rueckmeldung> {
|
export async function ideeUebernehmen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
|||||||
import { mailSenden } from '@/server/benachrichtigung/mail'
|
import { mailSenden } from '@/server/benachrichtigung/mail'
|
||||||
import { pushSenden } from '@/server/benachrichtigung/push'
|
import { pushSenden } from '@/server/benachrichtigung/push'
|
||||||
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { telegramMeldung } from '@/server/integration/telegram'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
import { umgebung } from '@/server/umgebung'
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
@@ -37,6 +38,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
|
|||||||
anlaesse: daten.getAll('anlaesse'),
|
anlaesse: daten.getAll('anlaesse'),
|
||||||
perPush: daten.get('perPush') !== null,
|
perPush: daten.get('perPush') !== null,
|
||||||
perMail: daten.get('perMail') !== null,
|
perMail: daten.get('perMail') !== null,
|
||||||
|
perTelegram: daten.get('perTelegram') !== null,
|
||||||
ueberblickUm: daten.get('ueberblickUm'),
|
ueberblickUm: daten.get('ueberblickUm'),
|
||||||
ruheVon: daten.get('ruheVon'),
|
ruheVon: daten.get('ruheVon'),
|
||||||
ruheBis: daten.get('ruheBis'),
|
ruheBis: daten.get('ruheBis'),
|
||||||
@@ -57,6 +59,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
|
|||||||
anlaesse: gelesen.data.anlaesse.join(','),
|
anlaesse: gelesen.data.anlaesse.join(','),
|
||||||
perPush: gelesen.data.perPush,
|
perPush: gelesen.data.perPush,
|
||||||
perMail: gelesen.data.perMail,
|
perMail: gelesen.data.perMail,
|
||||||
|
perTelegram: gelesen.data.perTelegram,
|
||||||
ueberblickUm: gelesen.data.ueberblickUm,
|
ueberblickUm: gelesen.data.ueberblickUm,
|
||||||
ruheVon: gelesen.data.ruheVon,
|
ruheVon: gelesen.data.ruheVon,
|
||||||
ruheBis: gelesen.data.ruheBis,
|
ruheBis: gelesen.data.ruheBis,
|
||||||
@@ -79,6 +82,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
|
|||||||
anlaesse: werte.anlaesse,
|
anlaesse: werte.anlaesse,
|
||||||
perPush: werte.perPush,
|
perPush: werte.perPush,
|
||||||
perMail: werte.perMail,
|
perMail: werte.perMail,
|
||||||
|
perTelegram: werte.perTelegram,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,6 +107,7 @@ export async function probeSenden(): Promise<Rueckmeldung> {
|
|||||||
anlaesse: true,
|
anlaesse: true,
|
||||||
perPush: true,
|
perPush: true,
|
||||||
perMail: true,
|
perMail: true,
|
||||||
|
perTelegram: true,
|
||||||
ueberblickUm: true,
|
ueberblickUm: true,
|
||||||
ruheVon: true,
|
ruheVon: true,
|
||||||
ruheBis: true,
|
ruheBis: true,
|
||||||
@@ -112,8 +117,11 @@ export async function probeSenden(): Promise<Rueckmeldung> {
|
|||||||
})
|
})
|
||||||
const einstellung = alsEinstellung(zeile)
|
const einstellung = alsEinstellung(zeile)
|
||||||
|
|
||||||
if (!einstellung.perPush && !einstellung.perMail) {
|
if (!einstellung.perPush && !einstellung.perMail && !einstellung.perTelegram) {
|
||||||
return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Erst einen Weg einschalten — Push, E-Mail oder Telegram.',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const meldung = {
|
const meldung = {
|
||||||
@@ -153,6 +161,19 @@ export async function probeSenden(): Promise<Rueckmeldung> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
await auditSchreiben({
|
||||||
akteur: { id: akteur.id, email: akteur.email },
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
aktion: 'benachrichtigung.probe',
|
aktion: 'benachrichtigung.probe',
|
||||||
|
|||||||
@@ -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<Rueckmeldung> {
|
||||||
|
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<Rueckmeldung> {
|
||||||
|
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<Rueckmeldung> {
|
||||||
|
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.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user