Phase 7: Benachrichtigungen und Termine
Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP, ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite. Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr verlaesst, bekommt nur die Kurzfassung ohne Namen und Details. Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp (Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) -- beides im README begruendet. 56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c779266450
commit
0b8874da26
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw, ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Die Ansicht des Dienstausweises.
|
||||
*
|
||||
* Sie ist bewusst wie ein Ausweis aufgebaut und nicht wie eine Datenmaske:
|
||||
* groß der Name, darunter Wehr und Funktion, daneben der Code. Wer ihn
|
||||
* vorzeigt, hält ein Telefon hin — der Gegenüber soll in zwei Sekunden
|
||||
* erfassen, wen er vor sich hat.
|
||||
*
|
||||
* Der Code läuft nach fünf Minuten ab. Statt ihn stur zu erneuern zeigt die
|
||||
* Seite an, wie lange er noch gilt, und holt sich kurz vorher einen neuen.
|
||||
*/
|
||||
export function AusweisAnzeige({
|
||||
bild,
|
||||
name,
|
||||
dienstgrad,
|
||||
wehr,
|
||||
qualifikationen,
|
||||
gueltigSekunden,
|
||||
}: {
|
||||
bild: string;
|
||||
name: string;
|
||||
dienstgrad: string | null;
|
||||
wehr: string;
|
||||
qualifikationen: Array<{ name: string; kuerzel: string | null; gueltigBis: Date | null }>;
|
||||
gueltigSekunden: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [rest, setRest] = React.useState(gueltigSekunden);
|
||||
|
||||
React.useEffect(() => {
|
||||
setRest(gueltigSekunden);
|
||||
|
||||
const zeitgeber = setInterval(() => {
|
||||
setRest((wert) => {
|
||||
if (wert <= 1) {
|
||||
router.refresh();
|
||||
return gueltigSekunden;
|
||||
}
|
||||
return wert - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(zeitgeber);
|
||||
}, [bild, gueltigSekunden, router]);
|
||||
|
||||
const minuten = Math.floor(rest / 60);
|
||||
const sekunden = rest % 60;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dienstausweis</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Zum Vorzeigen an der Absperrung, beim Lehrgang oder bei einer Kontrolle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{name}</CardTitle>
|
||||
<CardDescription>
|
||||
{dienstgrad ? `${dienstgrad} · ` : ""}
|
||||
{wehr}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<Image
|
||||
src={bild}
|
||||
alt="Dienstausweis als QR-Code"
|
||||
width={420}
|
||||
height={420}
|
||||
unoptimized
|
||||
className="mx-auto h-auto w-full max-w-xs rounded-lg border border-[var(--border)] bg-white p-3"
|
||||
/>
|
||||
|
||||
<p className="text-center text-sm text-[var(--muted-foreground)]" aria-live="polite">
|
||||
Gültig noch {minuten > 0 ? `${minuten}:${String(sekunden).padStart(2, "0")} Minuten` : `${sekunden} Sekunden`}
|
||||
</p>
|
||||
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full bg-[var(--primary)] transition-all duration-1000"
|
||||
style={{ width: `${(rest / gueltigSekunden) * 100}%` }}
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{qualifikationen.length > 0 && (
|
||||
<div className="space-y-2 border-t border-[var(--border)] pt-4">
|
||||
<p className="text-sm font-medium">Gültige Qualifikationen</p>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{qualifikationen.map((eintrag) => (
|
||||
<li key={`${eintrag.name}-${eintrag.gueltigBis?.toISOString() ?? "unbefristet"}`}>
|
||||
<Badge variant="secondary" title={eintrag.name}>
|
||||
{eintrag.kuerzel ?? eintrag.name}
|
||||
{eintrag.gueltigBis ? ` bis ${alsDatum(eintrag.gueltigBis)}` : ""}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={() => router.refresh()}>
|
||||
<RefreshCw aria-hidden />
|
||||
Neuen Code anzeigen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-3 rounded-lg border border-[var(--border)] p-4 text-sm text-[var(--muted-foreground)]">
|
||||
<ShieldCheck className="mt-0.5 size-5 shrink-0" aria-hidden />
|
||||
<p>
|
||||
Der Code enthält keine Daten über dich, sondern eine kurzlebige Unterschrift. Wer ihn
|
||||
scannt, sieht Name, Wehr, Funktion und die gültigen Qualifikationen — nicht mehr. Nach
|
||||
fünf Minuten ist er wertlos, ein Foto davon nützt also nichts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { AUSWEIS_GUELTIG_SEKUNDEN, ausweisErzeugen } from "@/lib/dienstausweis";
|
||||
import { qrTextAlsDataUri } from "@/lib/stammdaten/qr";
|
||||
import { dienstgradText } from "@/lib/stammdaten/texte";
|
||||
import { AusweisAnzeige } from "@/app/(app)/ausweis/anzeige";
|
||||
|
||||
export const metadata = { title: "Dienstausweis" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Der digitale Dienstausweis.
|
||||
*
|
||||
* Was ein Ausweis aus Karton kann, kann diese Seite auch: vorzeigen, wer man
|
||||
* ist und zu welcher Wehr man gehört. Der Unterschied ist, dass der Prüfende
|
||||
* den Code scannt und die Angaben von FLORIAN bestätigt bekommt statt von
|
||||
* einem laminierten Stück Papier.
|
||||
*
|
||||
* Der Code wird auf dem Server signiert — mit AUTH_SECRET, das im Browser
|
||||
* nichts zu suchen hat — und gilt fünf Minuten.
|
||||
*/
|
||||
export default async function AusweisSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const person = await db.person.findUnique({
|
||||
where: { id: sitzung.personId },
|
||||
select: {
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
dienstgrad: true,
|
||||
eintrittsdatum: true,
|
||||
organisation: { select: { name: true, kurzname: true } },
|
||||
qualifikationen: {
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
OR: [{ gueltigBis: null }, { gueltigBis: { gte: new Date() } }],
|
||||
},
|
||||
orderBy: { erworbenAm: "desc" },
|
||||
select: { gueltigBis: true, typ: { select: { name: true, kuerzel: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return person;
|
||||
});
|
||||
|
||||
const code = ausweisErzeugen(sitzung.personId, sitzung.organisationId);
|
||||
const adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`;
|
||||
const bild = await qrTextAlsDataUri(adresse, 420);
|
||||
|
||||
return (
|
||||
<AusweisAnzeige
|
||||
bild={bild}
|
||||
name={daten ? `${daten.vorname} ${daten.nachname}` : sitzung.name}
|
||||
dienstgrad={dienstgradText(daten?.dienstgrad)}
|
||||
wehr={daten?.organisation.name ?? ""}
|
||||
qualifikationen={daten?.qualifikationen.map((eintrag) => ({
|
||||
name: eintrag.typ.name,
|
||||
kuerzel: eintrag.typ.kuerzel,
|
||||
gueltigBis: eintrag.gueltigBis,
|
||||
})) ?? []}
|
||||
gueltigSekunden={AUSWEIS_GUELTIG_SEKUNDEN}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { BellRing, Copy, Send, Trash2, TriangleAlert } from "lucide-react";
|
||||
import { EreignisTyp, Kanal } from "@prisma/client";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import type { AppRouter } from "@/server/routers/_app";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { KANAL_EXTERN, KANAL_TEXT } from "@/lib/benachrichtigung/vorlagen";
|
||||
import { ZUSTELLSTAND_TEXT } from "@/lib/termin/texte";
|
||||
import { pushAbonnieren, pushMoeglich } from "@/lib/benachrichtigung/push-browser";
|
||||
import { alsDatumZeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Die Einstellseite.
|
||||
*
|
||||
* Aufgebaut nach dem, was jemand nacheinander tun will:
|
||||
*
|
||||
* 1. Einen Weg einrichten (und ihn sofort ausprobieren).
|
||||
* 2. Ankreuzen, wofür er benutzt wird.
|
||||
* 3. Den Kalender abonnieren.
|
||||
*
|
||||
* Der Probeknopf steht bewusst direkt an jedem Weg. Ohne ihn merkt jemand
|
||||
* erst in drei Wochen, dass sein ntfy-Thema einen Tippfehler hat — dann,
|
||||
* wenn die Nachricht wichtig gewesen wäre.
|
||||
*/
|
||||
export function BenachrichtigungEinstellungen({ darfVorlagen }: { darfVorlagen: boolean }) {
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [hinweis, setHinweis] = React.useState<string | null>(null);
|
||||
|
||||
const moeglichkeiten = trpc.benachrichtigung.moeglichkeiten.useQuery();
|
||||
const kanaele = trpc.benachrichtigung.eigeneKanaele.useQuery();
|
||||
const verlauf = trpc.benachrichtigung.eigeneListe.useQuery({ anzahl: 20 });
|
||||
|
||||
const aendern = trpc.benachrichtigung.kanalAendern.useMutation({
|
||||
onSuccess: () => void werkzeuge.benachrichtigung.invalidate(),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const loeschen = trpc.benachrichtigung.kanalLoeschen.useMutation({
|
||||
onSuccess: () => void werkzeuge.benachrichtigung.invalidate(),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const probe = trpc.benachrichtigung.probe.useMutation({
|
||||
onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const ereignisse = moeglichkeiten.data?.ereignisse ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="benachrichtigungsfehler" />}
|
||||
|
||||
{hinweis && (
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
|
||||
{hinweis}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Deine Zustellwege</CardTitle>
|
||||
<CardDescription>
|
||||
Ohne einen Weg hier bekommst du nichts — außer dem, was in FLORIAN selbst steht.
|
||||
Niemand wird ungefragt benachrichtigt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{kanaele.data?.length === 0 && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Noch kein Zustellweg eingerichtet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{kanaele.data?.map((kanal) => (
|
||||
<div
|
||||
key={kanal.id}
|
||||
className="space-y-3 rounded-lg border border-[var(--border)] p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{KANAL_TEXT[kanal.kanal]}</span>
|
||||
{kanal.bezeichnung && (
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{kanal.bezeichnung}
|
||||
</span>
|
||||
)}
|
||||
{KANAL_EXTERN[kanal.kanal] && (
|
||||
<Badge variant="pruefungFaellig" title="Läuft über einen fremden Server">
|
||||
<TriangleAlert className="mr-1 size-3" aria-hidden />
|
||||
extern
|
||||
</Badge>
|
||||
)}
|
||||
{!kanal.aktiv && <Badge variant="gesperrt">stillgelegt</Badge>}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{kanal.letzteZustellung
|
||||
? `Zuletzt zugestellt: ${alsDatumZeit(kanal.letzteZustellung)}`
|
||||
: "Noch nichts zugestellt."}
|
||||
{kanal.fehlversuche > 0 && ` · ${kanal.fehlversuche} Fehlversuche`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={probe.isPending}
|
||||
onClick={() => {
|
||||
setHinweis(null);
|
||||
probe.mutate({ id: kanal.id });
|
||||
}}
|
||||
>
|
||||
<Send aria-hidden />
|
||||
Probe
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => loeschen.mutate({ id: kanal.id })}
|
||||
aria-label={`${KANAL_TEXT[kanal.kanal]} entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{KANAL_EXTERN[kanal.kanal] && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Über diesen Weg gehen nur Kurzfassungen ohne Namen, Adressen und
|
||||
Einsatzdetails — er verlässt das Netz der Wehr.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Wofür?</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{ereignisse.map((ereignis) => {
|
||||
const an = kanal.ereignisse.includes(ereignis.wert);
|
||||
return (
|
||||
<label key={ereignis.wert} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={an}
|
||||
onCheckedChange={(wert) =>
|
||||
aendern.mutate({
|
||||
id: kanal.id,
|
||||
ereignisse:
|
||||
wert === true
|
||||
? [...kanal.ereignisse, ereignis.wert]
|
||||
: kanal.ereignisse.filter((e) => e !== ereignis.wert),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{ereignis.text}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<KanalAnlegen
|
||||
moeglichkeiten={moeglichkeiten.data}
|
||||
fertig={() => void werkzeuge.benachrichtigung.invalidate()}
|
||||
fehler={setMeldung}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Kalenderzugaenge fehler={setMeldung} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zuletzt an dich verschickt</CardTitle>
|
||||
<CardDescription>
|
||||
Was FLORIAN dir geschickt hat — und ob es angekommen ist.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{verlauf.data?.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Noch nichts verschickt.</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{verlauf.data?.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.id}
|
||||
className="flex flex-wrap items-baseline justify-between gap-2 border-b border-[var(--border)] pb-2"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{eintrag.betreff ?? "(ohne Betreff)"}</span>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{" "}
|
||||
· {KANAL_TEXT[eintrag.kanal]} · {alsDatumZeit(eintrag.erstelltAm)}
|
||||
</span>
|
||||
{eintrag.fehler && (
|
||||
<span className="block text-[var(--destructive)]">{eintrag.fehler}</span>
|
||||
)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
eintrag.stand === "GESENDET"
|
||||
? "einsatzbereit"
|
||||
: eintrag.stand === "FEHLGESCHLAGEN"
|
||||
? "gesperrt"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{ZUSTELLSTAND_TEXT[eintrag.stand]}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{darfVorlagen && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Textvorlagen der Wehr</CardTitle>
|
||||
<CardDescription>
|
||||
Was in den Nachrichten steht, gilt für alle. Ohne eigene Vorlage greifen die
|
||||
eingebauten Texte.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/benachrichtigungen/vorlagen">Vorlagen bearbeiten</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Was die Oberfläche an Kanälen und Ereignissen anbieten darf.
|
||||
*
|
||||
* Direkt aus dem Router abgeleitet: kommt dort ein Kanal dazu, taucht er hier
|
||||
* ohne Zutun auf — und wenn eines der Felder wegfällt, meckert der Compiler.
|
||||
*/
|
||||
type Moeglichkeiten = inferRouterOutputs<AppRouter>["benachrichtigung"]["moeglichkeiten"];
|
||||
|
||||
/**
|
||||
* Einen neuen Weg einrichten.
|
||||
*
|
||||
* Jeder Kanal braucht etwas anderes, und das ist der Grund, warum es hier ein
|
||||
* Formular gibt statt eines einzelnen Knopfes:
|
||||
*
|
||||
* E-Mail nichts — es gilt die hinterlegte Adresse
|
||||
* Telegram die Chat-Kennung, die der Bot ausgibt
|
||||
* ntfy ein Thema, das man selbst wählt
|
||||
* Web-Push gar nichts von Hand: der Browser fragt und liefert die Daten
|
||||
*/
|
||||
function KanalAnlegen({
|
||||
moeglichkeiten,
|
||||
fertig,
|
||||
fehler,
|
||||
}: {
|
||||
moeglichkeiten: Moeglichkeiten | undefined;
|
||||
fertig: () => void;
|
||||
fehler: (nachricht: string) => void;
|
||||
}) {
|
||||
const [kanal, setKanal] = React.useState<Kanal>(Kanal.MAIL);
|
||||
const [ziel, setZiel] = React.useState("");
|
||||
const [bezeichnung, setBezeichnung] = React.useState("");
|
||||
|
||||
const anlegen = trpc.benachrichtigung.kanalAnlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setZiel("");
|
||||
setBezeichnung("");
|
||||
fertig();
|
||||
},
|
||||
onError: (aufruf) => fehler(aufruf.message),
|
||||
});
|
||||
|
||||
const eingerichtet =
|
||||
moeglichkeiten?.kanaele.find((eintrag) => eintrag.wert === kanal)?.eingerichtet ?? true;
|
||||
|
||||
async function speichern() {
|
||||
// Web-Push ist der einzige Weg, bei dem der Browser die Adresse liefert:
|
||||
// er fragt die Person um Erlaubnis und meldet sich beim Push-Dienst an.
|
||||
if (kanal === Kanal.WEBPUSH) {
|
||||
if (!moeglichkeiten?.webPushSchluessel) {
|
||||
fehler("Für Web-Push fehlen die Schlüssel auf dem Server (WEBPUSH_PUBLIC_KEY).");
|
||||
return;
|
||||
}
|
||||
|
||||
const abo = await pushAbonnieren(moeglichkeiten.webPushSchluessel).catch((grund: Error) => {
|
||||
fehler(grund.message);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!abo) return;
|
||||
|
||||
anlegen.mutate({
|
||||
kanal,
|
||||
ziel: abo.endpunkt,
|
||||
p256dh: abo.p256dh,
|
||||
auth: abo.auth,
|
||||
bezeichnung: bezeichnung || "Dieser Browser",
|
||||
ereignisse: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
anlegen.mutate({
|
||||
kanal,
|
||||
ziel: kanal === Kanal.MAIL ? "" : ziel,
|
||||
bezeichnung: bezeichnung || null,
|
||||
ereignisse: [],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-dashed border-[var(--border)] p-4">
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
<BellRing className="size-4" aria-hidden />
|
||||
Weg hinzufügen
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kanal-art">Über</Label>
|
||||
<Select value={kanal} onValueChange={(wert) => setKanal(wert as Kanal)}>
|
||||
<SelectTrigger id="kanal-art">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{moeglichkeiten?.kanaele.map((eintrag) => (
|
||||
<SelectItem key={eintrag.wert} value={eintrag.wert}>
|
||||
{eintrag.text}
|
||||
{!eintrag.eingerichtet && " (nicht eingerichtet)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{kanal !== Kanal.MAIL && kanal !== Kanal.WEBPUSH && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kanal-ziel">
|
||||
{kanal === Kanal.TELEGRAM ? "Chat-Kennung" : "Thema"}
|
||||
</Label>
|
||||
<Input
|
||||
id="kanal-ziel"
|
||||
value={ziel}
|
||||
onChange={(ereignis) => setZiel(ereignis.target.value)}
|
||||
placeholder={
|
||||
kanal === Kanal.TELEGRAM
|
||||
? "123456789"
|
||||
: (moeglichkeiten?.ntfyVorschlag ?? "florian-…")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kanal-bezeichnung">Name (freiwillig)</Label>
|
||||
<Input
|
||||
id="kanal-bezeichnung"
|
||||
value={bezeichnung}
|
||||
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
|
||||
placeholder="Diensthandy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kanal === Kanal.NTFY && moeglichkeiten?.ntfyServer && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
In der ntfy-App den Server <code>{moeglichkeiten.ntfyServer}</code> eintragen und
|
||||
dieses Thema abonnieren. Der Themenname ist das Geheimnis — wer ihn kennt, liest mit.
|
||||
Deshalb einen langen, zufälligen nehmen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{kanal === Kanal.WEBPUSH && !pushMoeglich() && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Dieser Browser kann kein Web-Push. Auf dem iPhone geht es erst, wenn FLORIAN zum
|
||||
Startbildschirm hinzugefügt wurde.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!eingerichtet && (
|
||||
<p className="text-xs text-[var(--pruefung-faellig)]">
|
||||
Dieser Weg ist auf dem Server nicht eingerichtet. Du kannst ihn eintragen, aber es
|
||||
wird nichts ankommen, bis der Administrator ihn aktiviert.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void speichern()}
|
||||
disabled={
|
||||
anlegen.isPending ||
|
||||
(kanal !== Kanal.MAIL && kanal !== Kanal.WEBPUSH && !ziel.trim())
|
||||
}
|
||||
>
|
||||
{anlegen.isPending ? "Wird eingerichtet …" : "Einrichten"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die Kalenderadressen fürs Handy. */
|
||||
function Kalenderzugaenge({ fehler }: { fehler: (nachricht: string) => void }) {
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const [bezeichnung, setBezeichnung] = React.useState("");
|
||||
const [nurEigene, setNurEigene] = React.useState(false);
|
||||
const [neueAdresse, setNeueAdresse] = React.useState<string | null>(null);
|
||||
|
||||
const zugaenge = trpc.benachrichtigung.kalenderzugaenge.useQuery();
|
||||
|
||||
const anlegen = trpc.benachrichtigung.kalenderzugangAnlegen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
setNeueAdresse(ergebnis.adresse);
|
||||
setBezeichnung("");
|
||||
void werkzeuge.benachrichtigung.invalidate();
|
||||
},
|
||||
onError: (aufruf) => fehler(aufruf.message),
|
||||
});
|
||||
|
||||
const widerrufen = trpc.benachrichtigung.kalenderzugangWiderrufen.useMutation({
|
||||
onSuccess: () => void werkzeuge.benachrichtigung.invalidate(),
|
||||
onError: (aufruf) => fehler(aufruf.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kalender abonnieren</CardTitle>
|
||||
<CardDescription>
|
||||
Die Termine der Wehr im Kalender des Handys — neben allem anderen, ohne FLORIAN zu
|
||||
öffnen. Die Adresse ist ein Schlüssel: wer sie hat, sieht die Termine.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{neueAdresse && (
|
||||
<div className="space-y-2 rounded-md border border-[var(--primary)] bg-[var(--muted)] p-3">
|
||||
<p className="text-sm font-medium">
|
||||
Diese Adresse wird nur jetzt angezeigt — bitte gleich eintragen.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={neueAdresse} className="font-mono text-xs" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Adresse kopieren"
|
||||
onClick={() => void navigator.clipboard?.writeText(neueAdresse)}
|
||||
>
|
||||
<Copy aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zugaenge.data?.map((zugang) => (
|
||||
<div
|
||||
key={zugang.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--border)] pb-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{zugang.bezeichnung}</span>
|
||||
{zugang.nurEigene && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
nur eigene
|
||||
</Badge>
|
||||
)}
|
||||
{!zugang.aktiv && (
|
||||
<Badge variant="gesperrt" className="ml-2">
|
||||
widerrufen
|
||||
</Badge>
|
||||
)}
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{zugang.zugriffe} Abrufe
|
||||
{zugang.letzterZugriff && `, zuletzt ${alsDatumZeit(zugang.letzterZugriff)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{zugang.aktiv && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => widerrufen.mutate({ id: zugang.id })}
|
||||
>
|
||||
Widerrufen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kalender-name">Für welches Gerät?</Label>
|
||||
<Input
|
||||
id="kalender-name"
|
||||
value={bezeichnung}
|
||||
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
|
||||
placeholder="Mein Handy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 pb-2 text-sm">
|
||||
<Checkbox
|
||||
checked={nurEigene}
|
||||
onCheckedChange={(wert) => setNurEigene(wert === true)}
|
||||
/>
|
||||
Nur Termine mit meiner Rückmeldung
|
||||
</label>
|
||||
|
||||
<Button
|
||||
className="mb-0.5"
|
||||
disabled={!bezeichnung.trim() || anlegen.isPending}
|
||||
onClick={() => anlegen.mutate({ bezeichnung, nurEigene })}
|
||||
>
|
||||
Adresse erzeugen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { BenachrichtigungEinstellungen } from "@/app/(app)/benachrichtigungen/einstellungen";
|
||||
|
||||
export const metadata = { title: "Benachrichtigungen" };
|
||||
|
||||
/**
|
||||
* Die eigenen Benachrichtigungswege.
|
||||
*
|
||||
* Der Satz, der über der ganzen Seite steht und der auch die Technik darunter
|
||||
* bestimmt: **niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
|
||||
* bekommt nichts. Es gibt keine Voreinstellung, die alle anmeldet.
|
||||
*/
|
||||
export default async function BenachrichtigungenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Benachrichtigungen</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Wo du worüber informiert werden willst — und der Kalender fürs Handy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BenachrichtigungEinstellungen
|
||||
darfVorlagen={hatRecht(sitzung, "benachrichtigung.vorlagen")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, RotateCcw } from "lucide-react";
|
||||
import { EreignisTyp, Kanal } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { EREIGNIS_TEXT, KANAL_EXTERN, KANAL_TEXT } from "@/lib/benachrichtigung/vorlagen";
|
||||
|
||||
/**
|
||||
* Vorlagen bearbeiten.
|
||||
*
|
||||
* Der Aufbau folgt dem, was schiefgehen kann: links der Text, rechts die
|
||||
* Vorschau mit Beispielwerten. Ein Platzhalter, den es für dieses Ereignis
|
||||
* nicht gibt, bleibt in der Vorschau als `{tippfehler}` stehen — und wird beim
|
||||
* Speichern abgelehnt, mit der Liste der erlaubten daneben.
|
||||
*/
|
||||
export function VorlagenBearbeiten() {
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const [ereignis, setEreignis] = React.useState<EreignisTyp>(EreignisTyp.TERMIN_NEU);
|
||||
const [kanal, setKanal] = React.useState<Kanal>(Kanal.MAIL);
|
||||
const [betreff, setBetreff] = React.useState("");
|
||||
const [text, setText] = React.useState("");
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [gespeichert, setGespeichert] = React.useState(false);
|
||||
|
||||
/**
|
||||
* Den passenden Text laden — aber nur einmal je Auswahl.
|
||||
*
|
||||
* Der Merker ist wichtiger, als er aussieht: die Liste wird im Hintergrund
|
||||
* nachgeladen (nach dem Speichern, beim Zurückkommen ins Fenster). Ohne ihn
|
||||
* würde jedes Nachladen den Text überschreiben, den gerade jemand tippt —
|
||||
* ein Fehler, der sich beim Ausprobieren kaum zeigt und im Alltag ständig.
|
||||
*/
|
||||
const geladenFuer = React.useRef<string | null>(null);
|
||||
|
||||
const vorlagen = trpc.benachrichtigung.vorlagen.useQuery();
|
||||
|
||||
const vorschau = trpc.benachrichtigung.vorschau.useQuery(
|
||||
{ ereignis, kanal, betreff: betreff || null, text },
|
||||
{ enabled: text.length > 0 },
|
||||
);
|
||||
|
||||
const speichern = trpc.benachrichtigung.vorlageSpeichern.useMutation({
|
||||
onSuccess: () => {
|
||||
setGespeichert(true);
|
||||
setMeldung(null);
|
||||
void werkzeuge.benachrichtigung.invalidate();
|
||||
},
|
||||
onError: (fehler) => {
|
||||
setGespeichert(false);
|
||||
setMeldung(fehler.message);
|
||||
},
|
||||
});
|
||||
|
||||
const zuruecksetzen = trpc.benachrichtigung.vorlageZuruecksetzen.useMutation({
|
||||
onSuccess: () => {
|
||||
// Den Merker freigeben, damit der eingebaute Text wieder eingelesen wird.
|
||||
geladenFuer.current = null;
|
||||
void werkzeuge.benachrichtigung.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const eigene = vorlagen.data?.eigene.find(
|
||||
(eintrag) => eintrag.ereignis === ereignis && eintrag.kanal === kanal,
|
||||
);
|
||||
|
||||
const standard = vorlagen.data?.standard.find((eintrag) => eintrag.ereignis === ereignis);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!vorlagen.data) return;
|
||||
|
||||
const auswahl = `${ereignis}:${kanal}`;
|
||||
if (geladenFuer.current === auswahl) return;
|
||||
geladenFuer.current = auswahl;
|
||||
|
||||
setGespeichert(false);
|
||||
setMeldung(null);
|
||||
|
||||
if (eigene) {
|
||||
setBetreff(eigene.betreff ?? "");
|
||||
setText(eigene.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (standard) {
|
||||
setBetreff(standard.lang.betreff);
|
||||
setText(KANAL_EXTERN[kanal] ? standard.lang.kurz : standard.lang.text);
|
||||
}
|
||||
}, [ereignis, kanal, eigene, standard, vorlagen.data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/benachrichtigungen">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Benachrichtigungen
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorlage-ereignis">Wofür</Label>
|
||||
<Select value={ereignis} onValueChange={(wert) => setEreignis(wert as EreignisTyp)}>
|
||||
<SelectTrigger id="vorlage-ereignis" className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(EreignisTyp).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{EREIGNIS_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorlage-kanal">Über</Label>
|
||||
<Select value={kanal} onValueChange={(wert) => setKanal(wert as Kanal)}>
|
||||
<SelectTrigger id="vorlage-kanal" className="w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(Kanal).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{KANAL_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{KANAL_EXTERN[kanal] && (
|
||||
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
|
||||
<strong>{KANAL_TEXT[kanal]} verlässt das Netz der Wehr.</strong> Was hier steht, läuft
|
||||
über einen fremden Server. Deshalb gehören dort keine Namen, keine Adressen und keine
|
||||
Einsatzdetails hinein — ein Hinweis genügt, das Ausführliche steht in FLORIAN.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Text</CardTitle>
|
||||
<CardDescription>
|
||||
{eigene
|
||||
? "Eine eigene Vorlage ist hinterlegt."
|
||||
: "Noch keine eigene Vorlage — hier steht der eingebaute Text."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{kanal === Kanal.MAIL && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorlage-betreff">Betreff</Label>
|
||||
<Input
|
||||
id="vorlage-betreff"
|
||||
value={betreff}
|
||||
onChange={(ereignisDaten) => setBetreff(ereignisDaten.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorlage-text">Nachricht</Label>
|
||||
<Textarea
|
||||
id="vorlage-text"
|
||||
rows={10}
|
||||
value={text}
|
||||
onChange={(ereignisDaten) => setText(ereignisDaten.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Verfügbare Platzhalter</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{standard?.platzhalter.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setText((bisher) => `${bisher}{${name}}`)}
|
||||
title="Anhängen"
|
||||
>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{`{${name}}`}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="vorlagenfehler" />}
|
||||
{gespeichert && (
|
||||
<p className="text-sm text-[var(--einsatzbereit)]">Vorlage gespeichert.</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
speichern.mutate({
|
||||
ereignis,
|
||||
kanal,
|
||||
betreff: kanal === Kanal.MAIL ? betreff || null : null,
|
||||
text,
|
||||
aktiv: true,
|
||||
})
|
||||
}
|
||||
disabled={!text.trim() || speichern.isPending}
|
||||
>
|
||||
{speichern.isPending ? "Wird gespeichert …" : "Speichern"}
|
||||
</Button>
|
||||
|
||||
{eigene && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => zuruecksetzen.mutate({ id: eigene.id })}
|
||||
disabled={zuruecksetzen.isPending}
|
||||
>
|
||||
<RotateCcw aria-hidden />
|
||||
Auf Standard zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vorschau</CardTitle>
|
||||
<CardDescription>
|
||||
Mit erfundenen Beispielwerten. Bleibt ein Platzhalter stehen, gibt es ihn für
|
||||
dieses Ereignis nicht.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-4">
|
||||
{kanal === Kanal.MAIL && vorschau.data?.betreff && (
|
||||
<p className="mb-3 border-b border-[var(--border)] pb-2 font-medium">
|
||||
{vorschau.data.betreff}
|
||||
</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap text-sm">
|
||||
{vorschau.data?.text ?? "Noch kein Text."}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { VorlagenBearbeiten } from "@/app/(app)/benachrichtigungen/vorlagen/bearbeiten";
|
||||
|
||||
export const metadata = { title: "Textvorlagen" };
|
||||
|
||||
/**
|
||||
* Die Textvorlagen der Wehr.
|
||||
*
|
||||
* Was hier steht, bekommen alle. Deshalb hängt die Seite an einem eigenen
|
||||
* Recht und nicht am Grundrecht, mit dem jeder seine eigenen Wege einstellt.
|
||||
*/
|
||||
export default async function VorlagenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
if (!hatRecht(sitzung, "benachrichtigung.vorlagen")) redirect("/benachrichtigungen");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Textvorlagen</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Was in den Benachrichtigungen steht. Ohne eigene Vorlage greift der eingebaute Text.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VorlagenBearbeiten />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,23 +9,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
||||
|
||||
export const metadata = { title: "Mitglied" };
|
||||
|
||||
const DIENSTGRAD_TEXT: Record<string, string> = {
|
||||
FEUERWEHRANWAERTER: "Feuerwehranwärter",
|
||||
FEUERWEHRMANN: "Feuerwehrmann",
|
||||
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
|
||||
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
|
||||
LOESCHMEISTER: "Löschmeister",
|
||||
OBERLOESCHMEISTER: "Oberlöschmeister",
|
||||
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
|
||||
BRANDMEISTER: "Brandmeister",
|
||||
OBERBRANDMEISTER: "Oberbrandmeister",
|
||||
HAUPTBRANDMEISTER: "Hauptbrandmeister",
|
||||
KEIN: "—",
|
||||
};
|
||||
|
||||
/**
|
||||
* Mitgliedsdetail.
|
||||
*
|
||||
|
||||
@@ -14,23 +14,10 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
||||
|
||||
export const metadata = { title: "Mitglieder" };
|
||||
|
||||
const DIENSTGRAD_TEXT: Record<string, string> = {
|
||||
FEUERWEHRANWAERTER: "Feuerwehranwärter",
|
||||
FEUERWEHRMANN: "Feuerwehrmann",
|
||||
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
|
||||
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
|
||||
LOESCHMEISTER: "Löschmeister",
|
||||
OBERLOESCHMEISTER: "Oberlöschmeister",
|
||||
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
|
||||
BRANDMEISTER: "Brandmeister",
|
||||
OBERBRANDMEISTER: "Oberbrandmeister",
|
||||
HAUPTBRANDMEISTER: "Hauptbrandmeister",
|
||||
KEIN: "—",
|
||||
};
|
||||
|
||||
/**
|
||||
* Mitgliederliste, nach Abteilung gruppiert.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { TerminDetail } from "@/app/(app)/termine/[id]/termin-detail";
|
||||
|
||||
export const metadata = { title: "Termin" };
|
||||
|
||||
export default async function TerminSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<TerminDetail
|
||||
id={id}
|
||||
darfBearbeiten={hatRecht(sitzung, "termin.bearbeiten")}
|
||||
darfAnwesenheit={hatRecht(sitzung, "termin.anwesenheit")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, ClipboardList, Printer, Repeat } from "lucide-react";
|
||||
import { Teilnahmestand } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
import { TEILNAHME_TEXT, TERMINART_TEXT, nochHin } from "@/lib/termin/texte";
|
||||
import { alsDatum, alsUhrzeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Ein einzelner Termin.
|
||||
*
|
||||
* Drei Dinge passieren hier, und sie gehören zu verschiedenen Zeitpunkten:
|
||||
* zusagen (vorher), absagen (vorher, durch die Leitung), Anwesenheit abhaken
|
||||
* (nachher). Die Ansicht zeigt deshalb nach dem Termin einen anderen
|
||||
* Schwerpunkt als davor.
|
||||
*/
|
||||
export function TerminDetail({
|
||||
id,
|
||||
darfBearbeiten,
|
||||
darfAnwesenheit,
|
||||
}: {
|
||||
id: string;
|
||||
darfBearbeiten: boolean;
|
||||
darfAnwesenheit: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [absageOffen, setAbsageOffen] = React.useState(false);
|
||||
const [listeOffen, setListeOffen] = React.useState(false);
|
||||
|
||||
const termin = trpc.termin.einzeln.useQuery({ id });
|
||||
|
||||
const rueckmelden = trpc.termin.rueckmelden.useMutation({
|
||||
onSuccess: () => void werkzeuge.termin.invalidate(),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
if (termin.isLoading) {
|
||||
return <div className="h-64 animate-pulse rounded-lg bg-[var(--muted)]" aria-hidden />;
|
||||
}
|
||||
|
||||
if (termin.error || !termin.data) {
|
||||
return <Fehlermeldung meldung={termin.error?.message ?? "Termin nicht gefunden."} />;
|
||||
}
|
||||
|
||||
const daten = termin.data;
|
||||
const abgesagt = Boolean(daten.abgesagtAm);
|
||||
const vorbei = daten.beginn.getTime() < Date.now();
|
||||
|
||||
const zugesagt = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.ZUGESAGT);
|
||||
const warteliste = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.WARTELISTE);
|
||||
const abgemeldet = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.ABGESAGT);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild className="nicht-drucken">
|
||||
<Link href="/termine">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Terminen
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{daten.titel}</h1>
|
||||
<Badge variant="outline">{TERMINART_TEXT[daten.art]}</Badge>
|
||||
{daten.serieId && (
|
||||
<Badge variant="secondary">
|
||||
<Repeat className="mr-1 size-3" aria-hidden />
|
||||
Teil einer Serie
|
||||
</Badge>
|
||||
)}
|
||||
{abgesagt && <Badge variant="gesperrt">Abgesagt</Badge>}
|
||||
</div>
|
||||
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
{alsDatum(daten.beginn)}
|
||||
{!daten.ganztaegig && `, ${alsUhrzeit(daten.beginn)} Uhr`}
|
||||
{daten.ende && !daten.ganztaegig && ` bis ${alsUhrzeit(daten.ende)} Uhr`}
|
||||
{!vorbei && ` · ${nochHin(daten.beginn)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 nicht-drucken">
|
||||
<Button variant="outline" onClick={() => setListeOffen(true)}>
|
||||
<ClipboardList aria-hidden />
|
||||
Anwesenheitsliste
|
||||
</Button>
|
||||
|
||||
{darfBearbeiten && !abgesagt && (
|
||||
<Button variant="outline" onClick={() => setAbsageOffen(true)}>
|
||||
Termin absagen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{abgesagt && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-[var(--gesperrt)]/40 bg-[var(--gesperrt)]/10 p-4 text-sm"
|
||||
>
|
||||
<p className="font-medium">Dieser Termin fällt aus.</p>
|
||||
{daten.absageGrund && <p className="mt-1">Grund: {daten.absageGrund}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} />}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<Feld titel="Ort" wert={daten.ort} />
|
||||
<Feld
|
||||
titel="Leitung"
|
||||
wert={daten.leiter ? `${daten.leiter.vorname} ${daten.leiter.nachname}` : null}
|
||||
/>
|
||||
<Feld
|
||||
titel="Rückmeldung"
|
||||
wert={daten.anmeldungNoetig ? "wird erwartet" : "nicht nötig"}
|
||||
/>
|
||||
<Feld
|
||||
titel="Plätze"
|
||||
wert={
|
||||
daten.teilnehmerLimit
|
||||
? `${zugesagt.length} von ${daten.teilnehmerLimit} belegt`
|
||||
: "unbegrenzt"
|
||||
}
|
||||
/>
|
||||
{daten.anmeldeschluss && (
|
||||
<Feld titel="Anmeldeschluss" wert={alsDatum(daten.anmeldeschluss)} />
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{daten.beschreibung && (
|
||||
<div className="border-t border-[var(--border)] pt-4">
|
||||
<p className="whitespace-pre-wrap text-sm">{daten.beschreibung}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="nicht-drucken">
|
||||
<CardHeader>
|
||||
<CardTitle>Deine Rückmeldung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{abgesagt ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Der Termin ist abgesagt — eine Rückmeldung ändert daran nichts.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
{daten.eigenerStand
|
||||
? `Aktuell: ${TEILNAHME_TEXT[daten.eigenerStand]}`
|
||||
: "Noch keine Rückmeldung."}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant={daten.eigenerStand === "ZUGESAGT" ? "default" : "outline"}
|
||||
disabled={rueckmelden.isPending}
|
||||
onClick={() => rueckmelden.mutate({ terminId: id, stand: "ZUGESAGT" })}
|
||||
>
|
||||
Zusagen
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant={daten.eigenerStand === "ABGESAGT" ? "default" : "outline"}
|
||||
disabled={rueckmelden.isPending}
|
||||
onClick={() => rueckmelden.mutate({ terminId: id, stand: "ABGESAGT" })}
|
||||
>
|
||||
Absagen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{daten.freiePlaetze === 0 && daten.eigenerStand !== "ZUGESAGT" && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Die Plätze sind belegt. Eine Zusage stellt dich auf die Warteliste — du
|
||||
rückst nach, sobald jemand absagt.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Teilnehmerliste titel={`Zugesagt (${zugesagt.length})`} eintraege={zugesagt} />
|
||||
{warteliste.length > 0 && (
|
||||
<Teilnehmerliste
|
||||
titel={`Warteliste (${warteliste.length})`}
|
||||
eintraege={warteliste}
|
||||
nummeriert
|
||||
/>
|
||||
)}
|
||||
<Teilnehmerliste titel={`Abgesagt (${abgemeldet.length})`} eintraege={abgemeldet} mitGrund />
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<AbsageDialog
|
||||
offen={absageOffen}
|
||||
terminId={id}
|
||||
inSerie={Boolean(daten.serieId)}
|
||||
schliessen={() => setAbsageOffen(false)}
|
||||
fertig={() => {
|
||||
setAbsageOffen(false);
|
||||
void werkzeuge.termin.invalidate();
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AnwesenheitDialog
|
||||
offen={listeOffen}
|
||||
terminId={id}
|
||||
darfAendern={darfAnwesenheit}
|
||||
schliessen={() => setListeOffen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Feld({ titel, wert }: { titel: string; wert: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-[var(--muted-foreground)]">{titel}</dt>
|
||||
<dd className="font-medium">{wert ?? "—"}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Teilnehmerliste({
|
||||
titel,
|
||||
eintraege,
|
||||
nummeriert = false,
|
||||
mitGrund = false,
|
||||
}: {
|
||||
titel: string;
|
||||
eintraege: Array<{
|
||||
id: string;
|
||||
grund: string | null;
|
||||
person: { vorname: string; nachname: string };
|
||||
}>;
|
||||
nummeriert?: boolean;
|
||||
mitGrund?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{titel}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{eintraege.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Niemand.</p>
|
||||
) : (
|
||||
<ol className="space-y-1 text-sm">
|
||||
{eintraege.map((eintrag, stelle) => (
|
||||
<li key={eintrag.id}>
|
||||
{nummeriert && (
|
||||
<span className="text-[var(--muted-foreground)]">{stelle + 1}. </span>
|
||||
)}
|
||||
{eintrag.person.nachname}, {eintrag.person.vorname}
|
||||
{mitGrund && eintrag.grund && (
|
||||
<span className="text-[var(--muted-foreground)]"> — {eintrag.grund}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Absage mit Grund — und der Frage, ob die ganze Serie gemeint ist. */
|
||||
function AbsageDialog({
|
||||
offen,
|
||||
terminId,
|
||||
inSerie,
|
||||
schliessen,
|
||||
fertig,
|
||||
}: {
|
||||
offen: boolean;
|
||||
terminId: string;
|
||||
inSerie: boolean;
|
||||
schliessen: () => void;
|
||||
fertig: () => void;
|
||||
}) {
|
||||
const [grund, setGrund] = React.useState("");
|
||||
const [ganzeSerie, setGanzeSerie] = React.useState(false);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const absagen = trpc.termin.absagen.useMutation({
|
||||
onSuccess: fertig,
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin absagen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Der Termin bleibt im Kalender stehen — durchgestrichen, mit dem Grund. Wer zugesagt
|
||||
hat, wird benachrichtigt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="absage-grund">Grund</Label>
|
||||
<Textarea
|
||||
id="absage-grund"
|
||||
rows={3}
|
||||
value={grund}
|
||||
onChange={(ereignis) => setGrund(ereignis.target.value)}
|
||||
placeholder="Sturmwarnung, Übungsgelände nicht befahrbar."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inSerie && (
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={ganzeSerie}
|
||||
onCheckedChange={(wert) => setGanzeSerie(wert === true)}
|
||||
/>
|
||||
<span>
|
||||
Alle folgenden Termine dieser Serie ebenfalls absagen.
|
||||
<span className="block text-[var(--muted-foreground)]">
|
||||
Vergangene bleiben unberührt.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} />}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={schliessen}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => absagen.mutate({ id: terminId, grund, ganzeSerie })}
|
||||
disabled={!grund.trim() || absagen.isPending}
|
||||
>
|
||||
{absagen.isPending ? "Wird abgesagt …" : "Absagen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Anwesenheitsliste.
|
||||
*
|
||||
* Zeigt **alle aktiven Mitglieder**, nicht nur die Angemeldeten — auf einem
|
||||
* Blatt zum Abhaken muss jeder stehen. Wer darf, hakt direkt hier ab; wer
|
||||
* nicht darf, druckt sie aus und hakt auf Papier ab. Beides kommt vor, und
|
||||
* beides ist in Ordnung.
|
||||
*/
|
||||
function AnwesenheitDialog({
|
||||
offen,
|
||||
terminId,
|
||||
darfAendern,
|
||||
schliessen,
|
||||
}: {
|
||||
offen: boolean;
|
||||
terminId: string;
|
||||
darfAendern: boolean;
|
||||
schliessen: () => void;
|
||||
}) {
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [aenderungen, setAenderungen] = React.useState<Record<string, boolean>>({});
|
||||
|
||||
const liste = trpc.termin.anwesenheitsliste.useQuery({ id: terminId }, { enabled: offen });
|
||||
|
||||
const speichern = trpc.termin.anwesenheit.useMutation({
|
||||
onSuccess: () => {
|
||||
setAenderungen({});
|
||||
void werkzeuge.termin.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const zeilen = liste.data?.zeilen ?? [];
|
||||
|
||||
function stand(personId: string, gespeichert: boolean | null): boolean {
|
||||
return aenderungen[personId] ?? gespeichert ?? false;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Anwesenheitsliste</DialogTitle>
|
||||
<DialogDescription>
|
||||
{liste.data?.termin.titel} — {liste.data && alsDatum(liste.data.termin.beginn)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{liste.isLoading && <p className="text-sm">Wird geladen …</p>}
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border)] text-left">
|
||||
<th className="py-2">Name</th>
|
||||
<th className="py-2">Gemeldet</th>
|
||||
<th className="py-2 text-right">Anwesend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zeilen.map((zeile) => (
|
||||
<tr key={zeile.id} className="border-b border-[var(--border)]">
|
||||
<td className="py-2">
|
||||
{zeile.nachname}, {zeile.vorname}
|
||||
</td>
|
||||
<td className="py-2 text-[var(--muted-foreground)]">
|
||||
{zeile.stand ? TEILNAHME_TEXT[zeile.stand] : "—"}
|
||||
{zeile.grund && ` (${zeile.grund})`}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{darfAendern ? (
|
||||
<Checkbox
|
||||
checked={stand(zeile.id, zeile.anwesend)}
|
||||
onCheckedChange={(wert) =>
|
||||
setAenderungen((bisher) => ({ ...bisher, [zeile.id]: wert === true }))
|
||||
}
|
||||
aria-label={`Anwesend: ${zeile.vorname} ${zeile.nachname}`}
|
||||
/>
|
||||
) : (
|
||||
// Ohne Recht zum Abhaken: ein Kästchen zum Ausdrucken.
|
||||
<span className="inline-block size-4 rounded border border-[var(--border)]" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} />}
|
||||
|
||||
<DialogFooter className="nicht-drucken">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer aria-hidden />
|
||||
Drucken
|
||||
</Button>
|
||||
|
||||
{darfAendern && (
|
||||
<Button
|
||||
disabled={Object.keys(aenderungen).length === 0 || speichern.isPending}
|
||||
onClick={() =>
|
||||
speichern.mutate({
|
||||
terminId,
|
||||
eintraege: Object.entries(aenderungen).map(([personId, anwesend]) => ({
|
||||
personId,
|
||||
anwesend,
|
||||
})),
|
||||
})
|
||||
}
|
||||
>
|
||||
{speichern.isPending ? "Wird gespeichert …" : "Anwesenheit speichern"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { TerminListe } from "@/app/(app)/termine/termin-liste";
|
||||
|
||||
export const metadata = { title: "Termine" };
|
||||
|
||||
/**
|
||||
* Der Terminkalender der Wehr.
|
||||
*
|
||||
* Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen — was auf dem
|
||||
* Jahresplan steht, steht hier, und wer will, hat es im Kalender seines
|
||||
* Handys.
|
||||
*
|
||||
* Die Ansicht zeigt standardmäßig nach vorn. Wer zurückschaut, tut das für
|
||||
* den Nachweis, und dafür gibt es die Anwesenheitsliste am einzelnen Termin.
|
||||
*/
|
||||
export default async function TermineSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Termine</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Dienste, Übungen und Veranstaltungen — mit Rückmeldung und Anwesenheit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TerminListe darfBearbeiten={hatRecht(sitzung, "termin.bearbeiten")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CalendarPlus, Check, Repeat, X } from "lucide-react";
|
||||
import { Serienmuster, TerminArt } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { MUSTER_TEXT } from "@/lib/termin/serie";
|
||||
import { TERMINART_TEXT, nochHin } from "@/lib/termin/texte";
|
||||
import { alsDatum, alsUhrzeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Die Terminliste.
|
||||
*
|
||||
* Bewusst keine Monatsansicht mit Kästchen: eine Wehr hat im Monat drei bis
|
||||
* acht Termine, und die will man untereinander lesen — mit Datum, Uhrzeit,
|
||||
* Ort und dem eigenen Stand. Ein Kalenderraster verbraucht den Platz für
|
||||
* neunundzwanzig leere Tage.
|
||||
*
|
||||
* Wer das Raster will, hat es ohnehin schon: im Handy, über das Abonnement.
|
||||
*/
|
||||
export function TerminListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||
const router = useRouter();
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const [art, setArt] = React.useState<TerminArt | "alle">("alle");
|
||||
const [nurEigene, setNurEigene] = React.useState(false);
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const termine = trpc.termin.liste.useQuery({
|
||||
art: art === "alle" ? undefined : art,
|
||||
nurEigene,
|
||||
anzahl: 200,
|
||||
});
|
||||
|
||||
const rueckmelden = trpc.termin.rueckmelden.useMutation({
|
||||
onSuccess: () => void werkzeuge.termin.invalidate(),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const eintraege = termine.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select value={art} onValueChange={(wert) => setArt(wert as TerminArt | "alle")}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue placeholder="Alle Arten" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">Alle Arten</SelectItem>
|
||||
{Object.values(TerminArt).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{TERMINART_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={nurEigene}
|
||||
onCheckedChange={(wert) => setNurEigene(wert === true)}
|
||||
aria-label="Nur Termine mit eigener Rückmeldung"
|
||||
/>
|
||||
Nur meine
|
||||
</label>
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/benachrichtigungen">Kalender abonnieren</Link>
|
||||
</Button>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<CalendarPlus aria-hidden />
|
||||
Termin anlegen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="terminfehler" />}
|
||||
{termine.error && <Fehlermeldung meldung={termine.error.message} kennung="terminfehler" />}
|
||||
|
||||
{termine.isLoading && (
|
||||
// Keine LadeZeilen aus den Stammdaten: die rendern <tr> und gehören in
|
||||
// eine Tabelle. Hier sind es Karten.
|
||||
<ul className="space-y-3" aria-hidden>
|
||||
{Array.from({ length: 5 }).map((_, zeile) => (
|
||||
<li
|
||||
key={zeile}
|
||||
className="h-24 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!termine.isLoading && eintraege.length === 0 && (
|
||||
<NichtsGefunden
|
||||
titel="Keine Termine"
|
||||
hinweis={
|
||||
nurEigene
|
||||
? "Zu keinem kommenden Termin liegt eine Rückmeldung von dir vor."
|
||||
: "Für die kommenden Monate ist nichts eingetragen."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{eintraege.map((termin) => {
|
||||
const abgesagt = Boolean(termin.abgesagtAm);
|
||||
const voll =
|
||||
termin.teilnehmerLimit !== null && termin.zusagen >= termin.teilnehmerLimit;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={termin.id}
|
||||
className={`rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 ${
|
||||
abgesagt ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/termine/${termin.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{termin.titel}
|
||||
</Link>
|
||||
<Badge variant="outline">{TERMINART_TEXT[termin.art]}</Badge>
|
||||
{termin.serieId && (
|
||||
<Badge variant="secondary" title={MUSTER_TEXT[termin.serienmuster]}>
|
||||
<Repeat className="mr-1 size-3" aria-hidden />
|
||||
Serie
|
||||
</Badge>
|
||||
)}
|
||||
{abgesagt && <Badge variant="gesperrt">Abgesagt</Badge>}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{alsDatum(termin.beginn)}
|
||||
{!termin.ganztaegig && `, ${alsUhrzeit(termin.beginn)} Uhr`}
|
||||
{termin.ort && ` · ${termin.ort}`}
|
||||
{" · "}
|
||||
{nochHin(termin.beginn)}
|
||||
</p>
|
||||
|
||||
{abgesagt && termin.absageGrund && (
|
||||
<p className="text-sm text-[var(--gesperrt)]">Grund: {termin.absageGrund}</p>
|
||||
)}
|
||||
|
||||
{termin.anmeldungNoetig && !abgesagt && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{termin.zusagen} zugesagt
|
||||
{termin.teilnehmerLimit !== null &&
|
||||
` von ${termin.teilnehmerLimit} Plätzen`}
|
||||
{termin.anmeldeschluss &&
|
||||
` · Anmeldeschluss ${alsDatum(termin.anmeldeschluss)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!abgesagt && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={termin.eigenerStand === "ZUGESAGT" ? "default" : "outline"}
|
||||
disabled={rueckmelden.isPending}
|
||||
onClick={() =>
|
||||
rueckmelden.mutate({ terminId: termin.id, stand: "ZUGESAGT" })
|
||||
}
|
||||
// Ein volles Kontingent hindert nicht am Zusagen — es
|
||||
// führt auf die Warteliste. Das steht in der Antwort.
|
||||
title={voll ? "Die Plätze sind belegt — du kommst auf die Warteliste." : undefined}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
{termin.eigenerStand === "WARTELISTE" ? "Warteliste" : "Zusagen"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={termin.eigenerStand === "ABGESAGT" ? "default" : "outline"}
|
||||
disabled={rueckmelden.isPending}
|
||||
onClick={() =>
|
||||
rueckmelden.mutate({ terminId: termin.id, stand: "ABGESAGT" })
|
||||
}
|
||||
>
|
||||
<X aria-hidden />
|
||||
Absagen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{darfBearbeiten && (
|
||||
<TerminAnlegen
|
||||
offen={offen}
|
||||
schliessen={() => setOffen(false)}
|
||||
fertig={(id) => {
|
||||
setOffen(false);
|
||||
void werkzeuge.termin.invalidate();
|
||||
router.push(`/termine/${id}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Datum von heute im Format, das ein datetime-local-Feld erwartet. */
|
||||
function naechsterAbend(): string {
|
||||
const wann = new Date();
|
||||
wann.setDate(wann.getDate() + 7);
|
||||
wann.setHours(19, 0, 0, 0);
|
||||
return zurEingabe(wann);
|
||||
}
|
||||
|
||||
function zurEingabe(wert: Date): string {
|
||||
const versatz = wert.getTimezoneOffset() * 60_000;
|
||||
return new Date(wert.getTime() - versatz).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Anlegeformular.
|
||||
*
|
||||
* Der wichtigste Teil ist die Zeile ganz unten: bei einer Serie steht dort,
|
||||
* wie viele Termine daraus werden. Wer „wöchentlich bis 31.12." einstellt,
|
||||
* soll vorher lesen, dass das siebzehn Einträge sind — nicht danach.
|
||||
*/
|
||||
function TerminAnlegen({
|
||||
offen,
|
||||
schliessen,
|
||||
fertig,
|
||||
}: {
|
||||
offen: boolean;
|
||||
schliessen: () => void;
|
||||
fertig: (id: string) => void;
|
||||
}) {
|
||||
const [titel, setTitel] = React.useState("");
|
||||
const [art, setArt] = React.useState<TerminArt>(TerminArt.DIENST);
|
||||
const [beginn, setBeginn] = React.useState(naechsterAbend());
|
||||
const [ende, setEnde] = React.useState("");
|
||||
const [ort, setOrt] = React.useState("");
|
||||
const [beschreibung, setBeschreibung] = React.useState("");
|
||||
const [muster, setMuster] = React.useState<Serienmuster>(Serienmuster.KEINE);
|
||||
const [serieBis, setSerieBis] = React.useState("");
|
||||
const [anmeldungNoetig, setAnmeldungNoetig] = React.useState(false);
|
||||
const [limit, setLimit] = React.useState("");
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const serie = muster !== Serienmuster.KEINE;
|
||||
|
||||
const vorschau = trpc.termin.serienvorschau.useQuery(
|
||||
{ beginn: new Date(beginn), muster, serieBis: new Date(serieBis || beginn) },
|
||||
{ enabled: offen && serie && Boolean(beginn) && Boolean(serieBis) },
|
||||
);
|
||||
|
||||
const anlegen = trpc.termin.anlegen.useMutation({
|
||||
onSuccess: (ergebnis) => fertig(ergebnis.id),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
function speichern() {
|
||||
setMeldung(null);
|
||||
anlegen.mutate({
|
||||
titel,
|
||||
art,
|
||||
beginn: new Date(beginn),
|
||||
ende: ende ? new Date(ende) : null,
|
||||
ort: ort || null,
|
||||
beschreibung: beschreibung || null,
|
||||
ganztaegig: false,
|
||||
anmeldungNoetig,
|
||||
teilnehmerLimit: anmeldungNoetig && limit ? Number(limit) : null,
|
||||
serienmuster: muster,
|
||||
serieBis: serie && serieBis ? new Date(serieBis) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Termin anlegen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ein einzelner Termin oder eine Serie. Anmeldung ist die Ausnahme — der
|
||||
Übungsabend braucht keine.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-titel">Titel</Label>
|
||||
<Input
|
||||
id="termin-titel"
|
||||
value={titel}
|
||||
onChange={(ereignis) => setTitel(ereignis.target.value)}
|
||||
placeholder="Übungsabend Löschangriff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-art">Art</Label>
|
||||
<Select value={art} onValueChange={(wert) => setArt(wert as TerminArt)}>
|
||||
<SelectTrigger id="termin-art">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(TerminArt).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{TERMINART_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-ort">Ort</Label>
|
||||
<Input
|
||||
id="termin-ort"
|
||||
value={ort}
|
||||
onChange={(ereignis) => setOrt(ereignis.target.value)}
|
||||
placeholder="Gerätehaus"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-beginn">Beginn</Label>
|
||||
<Input
|
||||
id="termin-beginn"
|
||||
type="datetime-local"
|
||||
value={beginn}
|
||||
onChange={(ereignis) => setBeginn(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-ende">Ende (freiwillig)</Label>
|
||||
<Input
|
||||
id="termin-ende"
|
||||
type="datetime-local"
|
||||
value={ende}
|
||||
onChange={(ereignis) => setEnde(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-beschreibung">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="termin-beschreibung"
|
||||
rows={3}
|
||||
value={beschreibung}
|
||||
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
|
||||
placeholder="Was gemacht wird, was mitzubringen ist."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-muster">Wiederholung</Label>
|
||||
<Select value={muster} onValueChange={(wert) => setMuster(wert as Serienmuster)}>
|
||||
<SelectTrigger id="termin-muster">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(Serienmuster).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{MUSTER_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{serie && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-serie-bis">Serie läuft bis</Label>
|
||||
<Input
|
||||
id="termin-serie-bis"
|
||||
type="date"
|
||||
value={serieBis}
|
||||
onChange={(ereignis) => setSerieBis(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serie && vorschau.data && (
|
||||
<p className="rounded-md bg-[var(--muted)] px-3 py-2 text-sm">
|
||||
Das legt <strong>{vorschau.data.anzahl} Termine</strong> an, vom{" "}
|
||||
{vorschau.data.erster ? alsDatum(vorschau.data.erster) : "—"} bis zum{" "}
|
||||
{vorschau.data.letzter ? alsDatum(vorschau.data.letzter) : "—"}.
|
||||
{vorschau.data.gekappt && " Mehr als das legt FLORIAN nicht auf einmal an."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 border-t border-[var(--border)] pt-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={anmeldungNoetig}
|
||||
onCheckedChange={(wert) => setAnmeldungNoetig(wert === true)}
|
||||
/>
|
||||
Rückmeldung verlangen
|
||||
</label>
|
||||
|
||||
{anmeldungNoetig && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="termin-limit">Plätze (leer = unbegrenzt)</Label>
|
||||
<Input
|
||||
id="termin-limit"
|
||||
type="number"
|
||||
min={1}
|
||||
value={limit}
|
||||
onChange={(ereignis) => setLimit(ereignis.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Ist die Zahl erreicht, kommen weitere Zusagen auf die Warteliste und rücken
|
||||
bei einer Absage von selbst nach.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} />}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={schliessen}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={speichern} disabled={!titel.trim() || anlegen.isPending}>
|
||||
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { herunterladen } from "@/lib/storage";
|
||||
import { ausweisPruefen } from "@/lib/dienstausweis";
|
||||
|
||||
/**
|
||||
* Das Passbild zum Dienstausweis.
|
||||
*
|
||||
* Ein Ausweis ohne Bild weist niemanden aus — man könnte den Code eines
|
||||
* anderen vorzeigen. Deshalb braucht die Prüfseite das Foto, und weil sie
|
||||
* ohne Anmeldung erreichbar ist, braucht sie einen Weg dorthin, der ohne
|
||||
* Sitzung auskommt.
|
||||
*
|
||||
* Der Weg ist **derselbe Code**. Er wird hier noch einmal geprüft, mit
|
||||
* derselben Frist wie auf der Prüfseite: fünf Minuten. Damit ist das Bild
|
||||
* genau so lange erreichbar wie der Ausweis selbst — und keine Sekunde
|
||||
* länger. Wer die Adresse speichert, hat in fünf Minuten einen toten Link.
|
||||
*
|
||||
* Ausgeliefert wird ausschließlich das Profilbild der Person aus dem Code.
|
||||
* Eine Dateikennung nimmt dieser Endpunkt bewusst nicht entgegen: sonst wäre
|
||||
* er ein Weg, mit einem gültigen Ausweis beliebige Dateien der Wehr zu lesen.
|
||||
*/
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
_anfrage: Request,
|
||||
{ params }: { params: Promise<{ code: string }> },
|
||||
): Promise<Response> {
|
||||
const { code } = await params;
|
||||
const ergebnis = ausweisPruefen(decodeURIComponent(code));
|
||||
|
||||
if ("fehler" in ergebnis) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const datei = await fuerOrganisation(ergebnis.organisationId, async (db) => {
|
||||
const person = await db.person.findUnique({
|
||||
where: { id: ergebnis.personId },
|
||||
select: {
|
||||
aktiv: true,
|
||||
geloeschtAm: true,
|
||||
avatar: { select: { objektSchluessel: true, mimeTyp: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!person || !person.aktiv || person.geloeschtAm) return null;
|
||||
return person.avatar;
|
||||
});
|
||||
|
||||
if (!datei) return new Response(null, { status: 404 });
|
||||
|
||||
try {
|
||||
const inhalt = await herunterladen(datei.objektSchluessel);
|
||||
|
||||
return new Response(new Uint8Array(inhalt), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": datei.mimeTyp,
|
||||
// So lange wie der Ausweis gilt, keine Sekunde länger.
|
||||
"Cache-Control": "private, max-age=300",
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Teilnahmestand } from "@prisma/client";
|
||||
import { ohneMandantenfilter, fuerOrganisation } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { icsErzeugen, type IcsTermin } from "@/lib/termin/ics";
|
||||
|
||||
/**
|
||||
* Der Terminkalender als Abonnement.
|
||||
*
|
||||
* Wer die Adresse in sein Handy einträgt, sieht die Termine der Wehr neben
|
||||
* seinen eigenen — ohne FLORIAN zu öffnen, ohne App, ohne Konto. Das ist der
|
||||
* Kanal mit der größten Wirkung pro Zeile Code in dieser Phase.
|
||||
*
|
||||
* **Die Adresse ist der Schlüssel.** Kalenderprogramme können sich nicht
|
||||
* anmelden; sie rufen stumpf eine URL ab, oft aus dem Hintergrund, oft ohne
|
||||
* jede Möglichkeit, eine Rückfrage zu beantworten. Deshalb steckt die
|
||||
* Berechtigung im Token — und deshalb:
|
||||
*
|
||||
* * Gespeichert wird nur der SHA-256 des Tokens. Wer die Datenbank liest,
|
||||
* bekommt keine funktionierende Adresse.
|
||||
* * Jeder Zugang lässt sich einzeln widerrufen. Ein verlorenes Handy kostet
|
||||
* einen Klick, nicht ein neues Passwort für alle.
|
||||
* * Ausgeliefert werden **nur Termine** — keine Anmeldelisten, keine
|
||||
* Personen außer dem Leiter, keine Mängel, keine Einsätze.
|
||||
*
|
||||
* Der Zeitraum ist begrenzt: ein halbes Jahr zurück, zwei Jahre nach vorn.
|
||||
* Weiter zurück will kein Kalender, und der Rückblick hilft, wenn jemand im
|
||||
* März nachsehen will, wann der Arbeitsdienst war.
|
||||
*/
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const RUECKBLICK_TAGE = 180;
|
||||
const VORAUSSCHAU_TAGE = 730;
|
||||
|
||||
export async function GET(
|
||||
_anfrage: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
): Promise<Response> {
|
||||
const { token } = await params;
|
||||
|
||||
// Kalenderprogramme mögen Adressen, die auf .ics enden — manche erkennen das
|
||||
// Format sonst nicht. Die Endung gehört nicht zum Token.
|
||||
const roh = token.replace(/\.ics$/i, "");
|
||||
const tokenHash = createHash("sha256").update(roh).digest("hex");
|
||||
|
||||
// Der Zugang wird ohne Mandantenfilter gesucht: zu diesem Zeitpunkt ist noch
|
||||
// unbekannt, zu welcher Wehr das Token gehört — genau wie bei der Anmeldung.
|
||||
// Danach läuft alles über fuerOrganisation.
|
||||
const zugang = await ohneMandantenfilter(
|
||||
"Kalender-Token auflösen — die Wehr steht erst danach fest",
|
||||
(db) =>
|
||||
db.kalenderzugang.findUnique({
|
||||
where: { tokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
organisationId: true,
|
||||
personId: true,
|
||||
nurEigene: true,
|
||||
aktiv: true,
|
||||
bezeichnung: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!zugang || !zugang.aktiv) {
|
||||
// Bewusst 404 und nicht 401: ein Kalenderprogramm würde bei 401 nach
|
||||
// Zugangsdaten fragen, die es nicht gibt. Und ein widerrufener Zugang soll
|
||||
// sich nicht von einem erfundenen unterscheiden.
|
||||
return new Response("Diese Kalenderadresse gilt nicht (mehr).", {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
const von = new Date(Date.now() - RUECKBLICK_TAGE * 864e5);
|
||||
const bis = new Date(Date.now() + VORAUSSCHAU_TAGE * 864e5);
|
||||
|
||||
const { organisation, person, termine } = await fuerOrganisation(
|
||||
zugang.organisationId,
|
||||
async (db) => {
|
||||
const [organisation, person, termine] = await Promise.all([
|
||||
db.organisation.findUnique({
|
||||
where: { id: zugang.organisationId },
|
||||
select: { name: true, kurzname: true },
|
||||
}),
|
||||
db.person.findUnique({
|
||||
where: { id: zugang.personId },
|
||||
select: { aktiv: true, geloeschtAm: true },
|
||||
}),
|
||||
db.termin.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
beginn: { gte: von, lte: bis },
|
||||
...(zugang.nurEigene
|
||||
? { anmeldungen: { some: { personId: zugang.personId } } }
|
||||
: {}),
|
||||
},
|
||||
orderBy: { beginn: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
beschreibung: true,
|
||||
ort: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
ganztaegig: true,
|
||||
art: true,
|
||||
abgesagtAm: true,
|
||||
geaendertAm: true,
|
||||
anmeldungen: {
|
||||
where: { personId: zugang.personId },
|
||||
select: { stand: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { organisation, person, termine };
|
||||
},
|
||||
);
|
||||
|
||||
// Wer aus der Wehr ausgetreten ist, behält kein Abonnement. Das fällt sonst
|
||||
// niemandem auf — der Kalender läuft still weiter.
|
||||
if (!person || !person.aktiv || person.geloeschtAm) {
|
||||
return new Response("Diese Kalenderadresse gilt nicht mehr.", {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
const eintraege: IcsTermin[] = termine.map((termin) => ({
|
||||
id: termin.id,
|
||||
titel: termin.titel,
|
||||
beschreibung: termin.beschreibung,
|
||||
ort: termin.ort,
|
||||
beginn: termin.beginn,
|
||||
ende: termin.ende,
|
||||
ganztaegig: termin.ganztaegig,
|
||||
art: termin.art,
|
||||
abgesagt: Boolean(termin.abgesagtAm),
|
||||
geaendertAm: termin.geaendertAm,
|
||||
eigenerStand: (termin.anmeldungen[0]?.stand ?? null) as Teilnahmestand | null,
|
||||
}));
|
||||
|
||||
const inhalt = icsErzeugen({
|
||||
name: zugang.nurEigene
|
||||
? `${organisation?.kurzname ?? "Feuerwehr"} — meine Termine`
|
||||
: `${organisation?.kurzname ?? "Feuerwehr"} — Termine`,
|
||||
termine: eintraege,
|
||||
adresse: env.APP_URL,
|
||||
});
|
||||
|
||||
// Zugriffe mitzählen, aber den Abruf nicht davon abhängig machen: ein
|
||||
// Kalenderprogramm fragt stündlich, und ein Schreibfehler darf den Kalender
|
||||
// nicht leeren.
|
||||
void fuerOrganisation(zugang.organisationId, (db) =>
|
||||
db.kalenderzugang.update({
|
||||
where: { id: zugang.id },
|
||||
data: { zugriffe: { increment: 1 }, letzterZugriff: new Date() },
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
|
||||
return new Response(inhalt, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": 'inline; filename="florian.ics"',
|
||||
// Kein Zwischenspeicher: eine Absage soll beim nächsten Abruf ankommen,
|
||||
// nicht wenn ein Proxy es für richtig hält.
|
||||
"Cache-Control": "no-store, max-age=0",
|
||||
// Der Kalender ist über die Adresse erreichbar — er soll trotzdem nicht
|
||||
// in einer Suchmaschine landen.
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { CircleAlert, ShieldCheck } from "lucide-react";
|
||||
import { AUSWEIS_FEHLER_TEXT, ausweisPruefen } from "@/lib/dienstausweis";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { alsDatum, alsUhrzeit } from "@/lib/utils";
|
||||
import { dienstgradText } from "@/lib/stammdaten/texte";
|
||||
|
||||
export const metadata = {
|
||||
title: "Dienstausweis prüfen",
|
||||
// Ein gescannter Ausweis gehört nicht in eine Suchmaschine.
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Die Prüfseite des Dienstausweises.
|
||||
*
|
||||
* Wer den QR-Code scannt, landet hier — ohne Anmeldung, denn wer prüft, hat
|
||||
* keinen Zugang zu FLORIAN. Die Seite liegt deshalb außerhalb der Gruppe
|
||||
* (app): kein Menü, keine Seitenleiste, kein Weg irgendwohin.
|
||||
*
|
||||
* **Sie zeigt an, sie lässt nicht hinein.** Der Code ist keine Berechtigung.
|
||||
* Was hier steht, steht auf jedem Ausweis aus Papier: Name, Wehr, Funktion,
|
||||
* gültige Qualifikationen. Nicht: Anschrift, Geburtsdatum, Telefonnummer.
|
||||
*
|
||||
* Und der wichtigste Teil ist die Zeitangabe. Ein Ausweis, der „gültig" sagt,
|
||||
* ohne zu sagen, seit wann, ist wertlos — der Prüfende muss sehen können, dass
|
||||
* der Code gerade erst erzeugt wurde und nicht vor drei Wochen abfotografiert.
|
||||
*/
|
||||
export default async function AusweisPruefseite({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ code: string }>;
|
||||
}) {
|
||||
const { code } = await params;
|
||||
const ergebnis = ausweisPruefen(decodeURIComponent(code));
|
||||
|
||||
if ("fehler" in ergebnis) {
|
||||
return (
|
||||
<Rahmen>
|
||||
<div className="rounded-lg border-2 border-[var(--destructive)] bg-[var(--card)] p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CircleAlert className="mt-0.5 size-6 shrink-0 text-[var(--destructive)]" aria-hidden />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Ausweis nicht gültig</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
{AUSWEIS_FEHLER_TEXT[ergebnis.fehler]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Rahmen>
|
||||
);
|
||||
}
|
||||
|
||||
const daten = await fuerOrganisation(ergebnis.organisationId, async (db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ergebnis.personId },
|
||||
select: {
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
dienstgrad: true,
|
||||
aktiv: true,
|
||||
geloeschtAm: true,
|
||||
avatarDateiId: true,
|
||||
organisation: { select: { name: true, ort: true } },
|
||||
qualifikationen: {
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
OR: [{ gueltigBis: null }, { gueltigBis: { gte: new Date() } }],
|
||||
},
|
||||
orderBy: { erworbenAm: "desc" },
|
||||
select: { gueltigBis: true, typ: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Ein gültig signierter Code für jemanden, der ausgetreten ist. Der Code
|
||||
// stimmt, der Ausweis nicht — das ist genau der Fall, für den es eine
|
||||
// Prüfseite gibt.
|
||||
if (!daten || !daten.aktiv || daten.geloeschtAm) {
|
||||
return (
|
||||
<Rahmen>
|
||||
<div className="rounded-lg border-2 border-[var(--destructive)] bg-[var(--card)] p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CircleAlert className="mt-0.5 size-6 shrink-0 text-[var(--destructive)]" aria-hidden />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Kein aktives Mitglied</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
Zu diesem Ausweis gibt es keine aktive Mitgliedschaft mehr.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Rahmen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Rahmen>
|
||||
<div className="overflow-hidden rounded-lg border-2 border-[var(--primary)] bg-[var(--card)]">
|
||||
<div className="flex items-center gap-3 bg-[var(--primary)] px-6 py-3 text-[var(--primary-foreground)]">
|
||||
<ShieldCheck className="size-6 shrink-0" aria-hidden />
|
||||
<p className="font-semibold">Ausweis gültig</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{daten.avatarDateiId && (
|
||||
// Absichtlich ein einfaches img: die Adresse gilt fünf Minuten,
|
||||
// da hat der Bildoptimierer von Next nichts zu zwischenlagern.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/ausweis/${encodeURIComponent(code)}/foto`}
|
||||
alt=""
|
||||
className="size-24 shrink-0 rounded-md border border-[var(--border)] object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xl font-semibold tracking-tight">
|
||||
{daten.vorname} {daten.nachname}
|
||||
</p>
|
||||
{dienstgradText(daten.dienstgrad) && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{dienstgradText(daten.dienstgrad)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--muted-foreground)]">Feuerwehr</dt>
|
||||
<dd className="font-medium">
|
||||
{daten.organisation.name}
|
||||
{daten.organisation.ort ? `, ${daten.organisation.ort}` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{daten.qualifikationen.length > 0 && (
|
||||
<div>
|
||||
<dt className="text-[var(--muted-foreground)]">Gültige Qualifikationen</dt>
|
||||
<dd>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{daten.qualifikationen.map((eintrag) => (
|
||||
<li key={eintrag.typ.name}>
|
||||
{eintrag.typ.name}
|
||||
{eintrag.gueltigBis && (
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{" "}
|
||||
— gültig bis {alsDatum(eintrag.gueltigBis)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<p className="border-t border-[var(--border)] pt-4 text-sm text-[var(--muted-foreground)]">
|
||||
Dieser Code wurde am {alsDatum(ergebnis.erzeugtAm)} um{" "}
|
||||
{alsUhrzeit(ergebnis.erzeugtAm)} Uhr erzeugt und gilt fünf Minuten. Ein älterer Code
|
||||
wird hier nicht angenommen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Rahmen>
|
||||
);
|
||||
}
|
||||
|
||||
function Rahmen({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--background)] p-4">
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
{children}
|
||||
<p className="text-center text-xs text-[var(--muted-foreground)]">
|
||||
Ausgestellt über FLORIAN. Diese Seite zeigt an — sie gewährt keinen Zugang.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Album,
|
||||
BellRing,
|
||||
BookOpenCheck,
|
||||
CalendarDays,
|
||||
ClipboardCheck,
|
||||
Flame,
|
||||
Gauge,
|
||||
GraduationCap,
|
||||
IdCard,
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
@@ -119,7 +121,21 @@ const EINTRAEGE: Eintrag[] = [
|
||||
titel: "Termine",
|
||||
pfad: "/termine",
|
||||
symbol: CalendarDays,
|
||||
rechte: ["ausbildung.lesen"],
|
||||
rechte: ["termin.lesen"],
|
||||
abPhase: 7,
|
||||
},
|
||||
{
|
||||
titel: "Dienstausweis",
|
||||
pfad: "/ausweis",
|
||||
symbol: IdCard,
|
||||
rechte: ["person.eigene"],
|
||||
abPhase: 7,
|
||||
},
|
||||
{
|
||||
titel: "Benachrichtigungen",
|
||||
pfad: "/benachrichtigungen",
|
||||
symbol: BellRing,
|
||||
rechte: ["benachrichtigung.eigene"],
|
||||
abPhase: 7,
|
||||
},
|
||||
{ titel: "Kasse", pfad: "/kasse", symbol: Wallet, rechte: ["kasse.lesen"], abPhase: 9 },
|
||||
@@ -137,7 +153,7 @@ const EINTRAEGE: Eintrag[] = [
|
||||
|
||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||
const AKTUELLE_PHASE = 6;
|
||||
const AKTUELLE_PHASE = 7;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -131,6 +131,19 @@ export const RECHTE = {
|
||||
// Terminal im Gerätehaus (Phase 4)
|
||||
"kiosk.anmelden": { bereich: "Terminal", bezeichnung: "Am Terminal anmelden" },
|
||||
"kiosk.erfassen": { bereich: "Terminal", bezeichnung: "Am Terminal Erfassungen vornehmen" },
|
||||
|
||||
// Termine und Benachrichtigungen (Phase 7)
|
||||
"termin.lesen": { bereich: "Termine", bezeichnung: "Terminkalender ansehen" },
|
||||
"termin.bearbeiten": { bereich: "Termine", bezeichnung: "Termine anlegen, ändern und absagen" },
|
||||
"termin.anwesenheit": { bereich: "Termine", bezeichnung: "Anwesenheit abhaken" },
|
||||
"benachrichtigung.eigene": {
|
||||
bereich: "Benachrichtigungen",
|
||||
bezeichnung: "Eigene Benachrichtigungswege einrichten",
|
||||
},
|
||||
"benachrichtigung.vorlagen": {
|
||||
bereich: "Benachrichtigungen",
|
||||
bezeichnung: "Textvorlagen der Wehr bearbeiten",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Recht = keyof typeof RECHTE;
|
||||
@@ -155,6 +168,10 @@ const GRUNDRECHTE: Recht[] = [
|
||||
"ausbildung.lesen",
|
||||
"qualifikation.lesen",
|
||||
"datei.lesen",
|
||||
// Den Kalender sieht jeder, und jeder richtet seine eigenen
|
||||
// Benachrichtigungswege selbst ein — das ist keine Sache des Kommandanten.
|
||||
"termin.lesen",
|
||||
"benachrichtigung.eigene",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -187,6 +204,9 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
|
||||
"kasse.lesen",
|
||||
"datei.hochladen",
|
||||
"datei.loeschen",
|
||||
"termin.bearbeiten",
|
||||
"termin.anwesenheit",
|
||||
"benachrichtigung.vorlagen",
|
||||
],
|
||||
|
||||
GERAETEWART: [
|
||||
@@ -219,6 +239,11 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
|
||||
"einsatz.bericht",
|
||||
"ausbildung.bearbeiten",
|
||||
"datei.hochladen",
|
||||
// Der Schriftführer führt den Terminkalender der Wehr — das ist seit jeher
|
||||
// sein Amt, lange bevor es dafür Software gab.
|
||||
"termin.bearbeiten",
|
||||
"termin.anwesenheit",
|
||||
"benachrichtigung.vorlagen",
|
||||
],
|
||||
|
||||
AUSBILDER: [
|
||||
@@ -226,6 +251,9 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
|
||||
"ausbildung.bearbeiten",
|
||||
"qualifikation.bearbeiten",
|
||||
"datei.hochladen",
|
||||
// Übungstermine anlegen und die Anwesenheit abhaken gehört zur Ausbildung.
|
||||
"termin.bearbeiten",
|
||||
"termin.anwesenheit",
|
||||
],
|
||||
|
||||
MITGLIED: [...GRUNDRECHTE],
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Web-Push im Browser anmelden.
|
||||
*
|
||||
* Läuft im Browser, nicht auf dem Server — deshalb eine eigene Datei ohne
|
||||
* `server-only`. Der Ablauf ist in jedem Browser gleich und besteht aus drei
|
||||
* Schritten, von denen jeder scheitern kann:
|
||||
*
|
||||
* 1. Den Service Worker registrieren. Ohne ihn gibt es keinen Push — eine
|
||||
* Meldung soll ja auch ankommen, wenn FLORIAN nicht offen ist.
|
||||
* 2. Die Person um Erlaubnis fragen. Das macht der Browser, nicht wir. Wer
|
||||
* ablehnt, wird nicht noch einmal gefragt — der Browser merkt es sich.
|
||||
* 3. Beim Push-Dienst anmelden. Heraus kommt eine Adresse und zwei
|
||||
* Schlüssel; damit kann der Server verschlüsselte Nachrichten schicken.
|
||||
*
|
||||
* Jeder Fehlerfall bekommt hier einen Satz, den ein Mensch versteht. „Push
|
||||
* subscription failed" hilft im Gerätehaus niemandem weiter.
|
||||
*/
|
||||
|
||||
export interface PushAbo {
|
||||
endpunkt: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
/** Ob dieser Browser überhaupt Push kann. */
|
||||
export function pushMoeglich(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Den öffentlichen VAPID-Schlüssel in die Form bringen, die der Browser
|
||||
* verlangt: base64url als Byte-Feld.
|
||||
*/
|
||||
function schluesselUmwandeln(base64url: string): Uint8Array<ArrayBuffer> {
|
||||
const auffuellen = "=".repeat((4 - (base64url.length % 4)) % 4);
|
||||
const base64 = (base64url + auffuellen).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const roh = window.atob(base64);
|
||||
|
||||
// Ausdrücklich über einen ArrayBuffer: pushManager.subscribe verlangt einen
|
||||
// BufferSource, und ein Uint8Array mit unbestimmtem Puffer erfüllt das nicht.
|
||||
const puffer = new ArrayBuffer(roh.length);
|
||||
const bytes = new Uint8Array(puffer);
|
||||
for (let stelle = 0; stelle < roh.length; stelle += 1) {
|
||||
bytes[stelle] = roh.charCodeAt(stelle);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Einen Schlüssel des Abos als base64url — so erwartet ihn der Server. */
|
||||
function alsBase64Url(puffer: ArrayBuffer | null): string {
|
||||
if (!puffer) return "";
|
||||
const bytes = new Uint8Array(puffer);
|
||||
let text = "";
|
||||
for (const byte of bytes) text += String.fromCharCode(byte);
|
||||
return window.btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export async function pushAbonnieren(oeffentlicherSchluessel: string): Promise<PushAbo> {
|
||||
if (!pushMoeglich()) {
|
||||
throw new Error(
|
||||
"Dieser Browser kann kein Web-Push. Auf dem iPhone geht es erst, wenn FLORIAN zum " +
|
||||
"Startbildschirm hinzugefügt wurde.",
|
||||
);
|
||||
}
|
||||
|
||||
const registrierung = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
// Ein frisch registrierter Worker ist nicht sofort bereit; ohne dieses
|
||||
// Warten schlägt subscribe() gelegentlich fehl.
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const erlaubnis = await Notification.requestPermission();
|
||||
|
||||
if (erlaubnis === "denied") {
|
||||
throw new Error(
|
||||
"Der Browser lässt keine Meldungen zu. Das lässt sich nur in den Einstellungen des " +
|
||||
"Browsers wieder ändern — FLORIAN kann nicht noch einmal fragen.",
|
||||
);
|
||||
}
|
||||
if (erlaubnis !== "granted") {
|
||||
throw new Error("Ohne Erlaubnis des Browsers gibt es keine Push-Meldungen.");
|
||||
}
|
||||
|
||||
const abo = await registrierung.pushManager.subscribe({
|
||||
// Pflicht seit Chrome 52: nur sichtbare Meldungen, keine stillen Pushes.
|
||||
// Das ist auch richtig so — ein Push, den niemand sieht, wäre eine
|
||||
// Standortabfrage im Hintergrund.
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: schluesselUmwandeln(oeffentlicherSchluessel),
|
||||
});
|
||||
|
||||
return {
|
||||
endpunkt: abo.endpoint,
|
||||
p256dh: alsBase64Url(abo.getKey("p256dh")),
|
||||
auth: alsBase64Url(abo.getKey("auth")),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ein bestehendes Abo dieses Browsers wieder abmelden. */
|
||||
export async function pushAbmelden(): Promise<boolean> {
|
||||
if (!pushMoeglich()) return false;
|
||||
|
||||
const registrierung = await navigator.serviceWorker.getRegistration("/");
|
||||
const abo = await registrierung?.pushManager.getSubscription();
|
||||
|
||||
return abo ? abo.unsubscribe() : false;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import "server-only";
|
||||
import { EreignisTyp, Kanal, Zustellstand } from "@prisma/client";
|
||||
import { fuerOrganisation, type MandantenClient } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { mail } from "@/lib/integrations/mail";
|
||||
import { telegram } from "@/lib/integrations/telegram";
|
||||
import { ntfy } from "@/lib/integrations/ntfy";
|
||||
import { webPush } from "@/lib/integrations/webpush";
|
||||
import { nachrichtBauen } from "@/lib/benachrichtigung/vorlagen";
|
||||
|
||||
/**
|
||||
* Der Weg einer Benachrichtigung, von der Ursache bis aufs Handy.
|
||||
*
|
||||
* Er hat zwei Hälften, und dass sie getrennt sind, ist der ganze Trick:
|
||||
*
|
||||
* 1. **Anlegen.** Passiert dort, wo das Ereignis entsteht — im Router, im
|
||||
* nächtlichen Lauf. Es wird nur eine Zeile geschrieben, sonst nichts. Das
|
||||
* dauert Millisekunden und kann nicht fehlschlagen, weil ein Mailserver
|
||||
* gerade nicht antwortet.
|
||||
* 2. **Zustellen.** Passiert im Worker. Er nimmt sich, was auf WARTET steht,
|
||||
* schickt es weg und vermerkt, wie es ausging.
|
||||
*
|
||||
* Wäre es eine Hälfte, hinge das Speichern eines Termins am Antwortverhalten
|
||||
* von Telegram. So ist die schlimmste Folge eines toten Mailservers eine
|
||||
* Benachrichtigung, die auf FEHLGESCHLAGEN steht und sichtbar ist.
|
||||
*
|
||||
* **Niemand wird ungefragt benachrichtigt.** Angelegt wird nur für Personen,
|
||||
* die für dieses Ereignis einen Zustellkanal eingerichtet haben. Es gibt keine
|
||||
* Voreinstellung, die stillschweigend alle einträgt — der einzige Kanal, der
|
||||
* ohne Zutun funktioniert, ist die Anzeige in FLORIAN selbst.
|
||||
*/
|
||||
|
||||
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
|
||||
console.info(`[benachrichtigung] ${nachricht}`, ...weiteres);
|
||||
}
|
||||
|
||||
export interface Ereignisdaten {
|
||||
ereignis: EreignisTyp;
|
||||
/** Womit die Platzhalter der Vorlage gefüllt werden. */
|
||||
werte: Record<string, string | number | null>;
|
||||
/** Worum es ging — für die Rückverfolgung in der Liste. */
|
||||
entitaet?: string;
|
||||
entitaetId?: string;
|
||||
/**
|
||||
* Auf diese Personen einschränken. Ohne Angabe bekommt jeder etwas, der das
|
||||
* Ereignis abonniert hat — das ist der Regelfall für „Prüfung fällig".
|
||||
* Für „Termin abgesagt" sind es dagegen die Angemeldeten.
|
||||
*/
|
||||
personen?: string[];
|
||||
/**
|
||||
* Diese Personen überspringen.
|
||||
*
|
||||
* Für Ereignisse, die zusätzlich noch auf einem anderen Weg zugestellt
|
||||
* werden: der Gerätewart, der die Prüferinnerung ohnehin als Sammelmail
|
||||
* bekommt, soll sie nicht ein zweites Mal als Push erhalten.
|
||||
*/
|
||||
ausschluss?: string[];
|
||||
/**
|
||||
* Auch dann anlegen, wenn niemand den Kanal abonniert hat — als
|
||||
* ÜBERSPRUNGEN. Damit steht in der Liste, dass das Ereignis stattfand und
|
||||
* niemanden erreichte. Für sicherheitsrelevante Dinge sinnvoll, sonst nur
|
||||
* Rauschen.
|
||||
*/
|
||||
auchOhneEmpfaenger?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigungen zu einem Ereignis anlegen.
|
||||
*
|
||||
* Läuft innerhalb eines vorhandenen Mandantenkontexts, damit sie in derselben
|
||||
* Transaktion entstehen wie das, was sie meldet: ein abgesagter Termin und die
|
||||
* Absagemeldungen dazu sind entweder beide da oder beide nicht.
|
||||
*/
|
||||
export async function benachrichtigungenAnlegenIn(
|
||||
db: MandantenClient,
|
||||
organisationId: string,
|
||||
daten: Ereignisdaten,
|
||||
): Promise<number> {
|
||||
// Einschränkung und Ausschluss in einer Bedingung: zwei getrennte
|
||||
// personId-Felder würden sich gegenseitig überschreiben.
|
||||
const personFilter = {
|
||||
...(daten.personen ? { in: daten.personen } : {}),
|
||||
...(daten.ausschluss?.length ? { notIn: daten.ausschluss } : {}),
|
||||
};
|
||||
|
||||
const kanaele = await db.zustellkanal.findMany({
|
||||
where: {
|
||||
aktiv: true,
|
||||
ereignisse: { has: daten.ereignis },
|
||||
...(Object.keys(personFilter).length > 0 ? { personId: personFilter } : {}),
|
||||
person: { aktiv: true, geloeschtAm: null },
|
||||
},
|
||||
select: { id: true, kanal: true, ziel: true, personId: true },
|
||||
});
|
||||
|
||||
// Die E-Mail-Adresse steht an der Person, nicht am Zustellkanal — ein
|
||||
// MAIL-Kanal ohne Ziel bedeutet „an meine hinterlegte Adresse".
|
||||
const ohneZiel = kanaele.filter((eintrag) => eintrag.kanal === Kanal.MAIL && !eintrag.ziel);
|
||||
const adressen = new Map<string, string>();
|
||||
|
||||
if (ohneZiel.length > 0) {
|
||||
const personen = await db.person.findMany({
|
||||
where: { id: { in: ohneZiel.map((eintrag) => eintrag.personId) } },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
for (const person of personen) {
|
||||
if (person.email) adressen.set(person.id, person.email);
|
||||
}
|
||||
}
|
||||
|
||||
const vorlagen = await db.benachrichtigungsvorlage.findMany({
|
||||
where: { ereignis: daten.ereignis, aktiv: true },
|
||||
select: { kanal: true, betreff: true, text: true },
|
||||
});
|
||||
const vorlageJeKanal = new Map(vorlagen.map((eintrag) => [eintrag.kanal, eintrag]));
|
||||
|
||||
const zeilen = kanaele
|
||||
.map((eintrag) => {
|
||||
const ziel = eintrag.ziel || adressen.get(eintrag.personId) || "";
|
||||
if (!ziel) return null;
|
||||
|
||||
const { betreff, text } = nachrichtBauen({
|
||||
ereignis: daten.ereignis,
|
||||
kanal: eintrag.kanal,
|
||||
werte: daten.werte,
|
||||
vorlage: vorlageJeKanal.get(eintrag.kanal) ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
organisationId,
|
||||
ereignis: daten.ereignis,
|
||||
kanal: eintrag.kanal,
|
||||
personId: eintrag.personId,
|
||||
// Push-Dienste kennen keinen Betreff — dort wird er zum Titel.
|
||||
betreff: betreff.slice(0, 200),
|
||||
text: text.slice(0, 2000),
|
||||
ziel: ziel.slice(0, 500),
|
||||
entitaet: daten.entitaet ?? null,
|
||||
entitaetId: daten.entitaetId ?? null,
|
||||
stand: Zustellstand.WARTET,
|
||||
};
|
||||
})
|
||||
.filter((zeile): zeile is NonNullable<typeof zeile> => zeile !== null);
|
||||
|
||||
if (zeilen.length === 0) {
|
||||
if (!daten.auchOhneEmpfaenger) return 0;
|
||||
|
||||
// Ein Eintrag ohne Empfänger, damit die Lücke sichtbar wird.
|
||||
await db.benachrichtigung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
ereignis: daten.ereignis,
|
||||
kanal: Kanal.MAIL,
|
||||
personId: daten.personen?.[0] ?? "",
|
||||
betreff: null,
|
||||
text: "Niemand hat dieses Ereignis abonniert.",
|
||||
ziel: "—",
|
||||
entitaet: daten.entitaet ?? null,
|
||||
entitaetId: daten.entitaetId ?? null,
|
||||
stand: Zustellstand.UEBERSPRUNGEN,
|
||||
},
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
const ergebnis = await db.benachrichtigung.createMany({ data: zeilen });
|
||||
return ergebnis.count;
|
||||
}
|
||||
|
||||
/** Dasselbe, wenn kein Mandantenkontext offen ist. */
|
||||
export async function benachrichtigungenAnlegen(
|
||||
organisationId: string,
|
||||
daten: Ereignisdaten,
|
||||
): Promise<number> {
|
||||
return fuerOrganisation(organisationId, (db) =>
|
||||
benachrichtigungenAnlegenIn(db, organisationId, daten),
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Zustellen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface Zustellbar {
|
||||
id: string;
|
||||
kanal: Kanal;
|
||||
ziel: string;
|
||||
betreff: string | null;
|
||||
text: string;
|
||||
versuche: number;
|
||||
ereignis: EreignisTyp;
|
||||
}
|
||||
|
||||
interface Zustellergebnis {
|
||||
erfolgreich: boolean;
|
||||
fehler?: string;
|
||||
/** Der Kanal existiert nicht mehr und wird stillgelegt. */
|
||||
abgelaufen?: boolean;
|
||||
}
|
||||
|
||||
/** Eine einzelne Nachricht über ihren Kanal schicken. */
|
||||
async function ueberKanal(
|
||||
eintrag: Zustellbar,
|
||||
zusatz: { p256dh?: string | null; auth?: string | null },
|
||||
): Promise<Zustellergebnis> {
|
||||
switch (eintrag.kanal) {
|
||||
case Kanal.MAIL:
|
||||
return mail().senden({
|
||||
an: eintrag.ziel,
|
||||
betreff: eintrag.betreff ?? "Nachricht aus FLORIAN",
|
||||
text: eintrag.text,
|
||||
});
|
||||
|
||||
case Kanal.TELEGRAM:
|
||||
// Der Titel steht fett voran; Telegram versteht das kleine HTML-Set.
|
||||
return telegram().senden(
|
||||
`<b>${maskieren(eintrag.betreff ?? "FLORIAN")}</b>\n${maskieren(eintrag.text)}`,
|
||||
eintrag.ziel,
|
||||
);
|
||||
|
||||
case Kanal.NTFY:
|
||||
return ntfy().senden({
|
||||
thema: eintrag.ziel,
|
||||
titel: eintrag.betreff ?? "FLORIAN",
|
||||
text: eintrag.text,
|
||||
dringlichkeit: dringlichkeit(eintrag.ereignis),
|
||||
symbole: ["fire_engine"],
|
||||
verweis: env.APP_URL,
|
||||
});
|
||||
|
||||
case Kanal.WEBPUSH: {
|
||||
if (!zusatz.p256dh || !zusatz.auth) {
|
||||
return { erfolgreich: false, abgelaufen: true, fehler: "Dem Push-Abo fehlen die Schlüssel." };
|
||||
}
|
||||
return webPush().senden(
|
||||
{ endpunkt: eintrag.ziel, p256dh: zusatz.p256dh, auth: zusatz.auth },
|
||||
{
|
||||
titel: eintrag.betreff ?? "FLORIAN",
|
||||
text: eintrag.text,
|
||||
verweis: env.APP_URL,
|
||||
// Gleiches Ereignis ersetzt die vorige Meldung, statt sie zu stapeln.
|
||||
kennzeichen: eintrag.ereignis,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return { erfolgreich: false, fehler: `Unbekannter Kanal: ${eintrag.kanal}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Telegram deutet <, > und & als Auszeichnung — im Fließtext maskieren. */
|
||||
function maskieren(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie laut eine Meldung sein darf.
|
||||
*
|
||||
* Ein gesperrtes Gerät klingelt auch im Ruhemodus durch, eine Terminerinnerung
|
||||
* nicht. Wer nachts von einer Erinnerung an den Arbeitsdienst geweckt wird,
|
||||
* schaltet die Benachrichtigungen ab — und bekommt dann auch die wichtigen
|
||||
* nicht mehr.
|
||||
*/
|
||||
function dringlichkeit(ereignis: EreignisTyp): "low" | "default" | "high" {
|
||||
switch (ereignis) {
|
||||
case EreignisTyp.MANGEL_GEMELDET:
|
||||
return "high";
|
||||
case EreignisTyp.TERMIN_ABGESAGT:
|
||||
case EreignisTyp.QUALIFIKATION_LAEUFT_AB:
|
||||
return "default";
|
||||
default:
|
||||
return "low";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles zustellen, was aussteht.
|
||||
*
|
||||
* Wird vom Worker aufgerufen — sowohl nach Zeitplan als auch angestoßen, wenn
|
||||
* etwas Eiliges entstanden ist.
|
||||
*/
|
||||
export async function offeneZustellen(
|
||||
organisationId: string,
|
||||
grenze = 200,
|
||||
): Promise<{ gesendet: number; fehlgeschlagen: number; aufgegeben: number }> {
|
||||
const offene = await fuerOrganisation(organisationId, (db) =>
|
||||
db.benachrichtigung.findMany({
|
||||
where: { stand: Zustellstand.WARTET, versuche: { lt: env.BENACHRICHTIGUNG_VERSUCHE } },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
take: grenze,
|
||||
select: {
|
||||
id: true,
|
||||
kanal: true,
|
||||
ziel: true,
|
||||
betreff: true,
|
||||
text: true,
|
||||
versuche: true,
|
||||
ereignis: true,
|
||||
personId: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (offene.length === 0) return { gesendet: 0, fehlgeschlagen: 0, aufgegeben: 0 };
|
||||
|
||||
// Die Web-Push-Schlüssel hängen am Zustellkanal, nicht an der Nachricht:
|
||||
// eine Nachricht soll nicht kopieren, was sich ändern kann.
|
||||
const pushZiele = offene.filter((eintrag) => eintrag.kanal === Kanal.WEBPUSH);
|
||||
const schluessel = new Map<string, { p256dh: string | null; auth: string | null }>();
|
||||
|
||||
if (pushZiele.length > 0) {
|
||||
const kanaele = await fuerOrganisation(organisationId, (db) =>
|
||||
db.zustellkanal.findMany({
|
||||
where: { kanal: Kanal.WEBPUSH, ziel: { in: pushZiele.map((eintrag) => eintrag.ziel) } },
|
||||
select: { ziel: true, p256dh: true, auth: true },
|
||||
}),
|
||||
);
|
||||
for (const kanal of kanaele) {
|
||||
schluessel.set(kanal.ziel, { p256dh: kanal.p256dh, auth: kanal.auth });
|
||||
}
|
||||
}
|
||||
|
||||
let gesendet = 0;
|
||||
let fehlgeschlagen = 0;
|
||||
let aufgegeben = 0;
|
||||
const totKanaele: string[] = [];
|
||||
/** Ziele, an die tatsächlich etwas ankam — nicht bloß versucht wurde. */
|
||||
const erreichteZiele = new Set<string>();
|
||||
|
||||
for (const eintrag of offene) {
|
||||
const ergebnis = await ueberKanal(eintrag, schluessel.get(eintrag.ziel) ?? {});
|
||||
const versuche = eintrag.versuche + 1;
|
||||
|
||||
if (ergebnis.erfolgreich) {
|
||||
gesendet += 1;
|
||||
erreichteZiele.add(eintrag.ziel);
|
||||
await fuerOrganisation(organisationId, (db) =>
|
||||
db.benachrichtigung.update({
|
||||
where: { id: eintrag.id },
|
||||
data: { stand: Zustellstand.GESENDET, versuche, gesendetAm: new Date(), fehler: null },
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ergebnis.abgelaufen) totKanaele.push(eintrag.ziel);
|
||||
|
||||
// Nach dem letzten Versuch bleibt es stehen — als FEHLGESCHLAGEN, damit
|
||||
// jemand es sieht, nicht als stiller Verlust.
|
||||
const endgueltig = ergebnis.abgelaufen || versuche >= env.BENACHRICHTIGUNG_VERSUCHE;
|
||||
if (endgueltig) aufgegeben += 1;
|
||||
else fehlgeschlagen += 1;
|
||||
|
||||
await fuerOrganisation(organisationId, (db) =>
|
||||
db.benachrichtigung.update({
|
||||
where: { id: eintrag.id },
|
||||
data: {
|
||||
versuche,
|
||||
stand: endgueltig ? Zustellstand.FEHLGESCHLAGEN : Zustellstand.WARTET,
|
||||
fehler: (ergebnis.fehler ?? "Unbekannter Fehler").slice(0, 500),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Kanäle, die der Push-Dienst als tot gemeldet hat, stilllegen statt löschen:
|
||||
// die Person soll in ihren Einstellungen sehen, dass dieses Gerät abgemeldet
|
||||
// wurde, und es neu erlauben können.
|
||||
if (totKanaele.length > 0) {
|
||||
await fuerOrganisation(organisationId, (db) =>
|
||||
db.zustellkanal.updateMany({
|
||||
where: { ziel: { in: totKanaele } },
|
||||
data: { aktiv: false, fehlversuche: { increment: 1 } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Erfolgreiche Zustellungen am Kanal vermerken — ein Kanal, an den seit
|
||||
// Monaten nichts mehr ankam, ist ein Hinweis auf ein totes Gerät.
|
||||
//
|
||||
// Nur die Ziele, an die wirklich etwas ankam. Ein Kanal, dessen Zustellung
|
||||
// gerade gescheitert ist, darf nicht als frisch bedient dastehen, bloß weil
|
||||
// im selben Lauf ein anderer geklappt hat.
|
||||
const erfolgreicheZiele = [...erreichteZiele].filter((ziel) => !totKanaele.includes(ziel));
|
||||
|
||||
if (erfolgreicheZiele.length > 0) {
|
||||
await fuerOrganisation(organisationId, (db) =>
|
||||
db.zustellkanal.updateMany({
|
||||
where: { ziel: { in: erfolgreicheZiele } },
|
||||
data: { letzteZustellung: new Date(), fehlversuche: 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protokoll(
|
||||
`${organisationId}: ${gesendet} zugestellt, ${fehlgeschlagen} später erneut, ${aufgegeben} aufgegeben`,
|
||||
);
|
||||
|
||||
return { gesendet, fehlgeschlagen, aufgegeben };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { EreignisTyp, Kanal } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Was in einer Benachrichtigung steht.
|
||||
*
|
||||
* Zwei Regeln bestimmen den Aufbau:
|
||||
*
|
||||
* 1. **Es gibt für alles einen Standardtext.** Eine Wehr, die nichts
|
||||
* einstellt, bekommt trotzdem brauchbare Nachrichten. Wer will, ändert
|
||||
* ihn — dann liegt eine Vorlage in der Datenbank und gewinnt.
|
||||
* 2. **Was nach draußen geht, ist knapp.** Telegram läuft über einen fremden
|
||||
* Server; dorthin gehen Hinweise ohne Namen, Adressen und Einsatzdetails.
|
||||
* Die ausführliche Fassung nimmt die E-Mail über den eigenen Mailserver.
|
||||
*
|
||||
* Platzhalter stehen in geschweiften Klammern und werden stumpf ersetzt. Ein
|
||||
* unbekannter Platzhalter bleibt stehen, statt zu verschwinden — so fällt ein
|
||||
* Tippfehler in der Vorlage auf, statt eine Lücke zu hinterlassen.
|
||||
*/
|
||||
|
||||
export const EREIGNIS_TEXT: Record<EreignisTyp, string> = {
|
||||
PRUEFUNG_FAELLIG: "Prüfung fällig",
|
||||
MANGEL_GEMELDET: "Mangel gemeldet",
|
||||
QUALIFIKATION_LAEUFT_AB: "Qualifikation läuft ab",
|
||||
TERMIN_NEU: "Neuer Termin",
|
||||
TERMIN_ERINNERUNG: "Erinnerung an einen Termin",
|
||||
TERMIN_ABGESAGT: "Termin abgesagt",
|
||||
EINSATZBERICHT_FREIGEGEBEN: "Einsatzbericht freigegeben",
|
||||
};
|
||||
|
||||
export const KANAL_TEXT: Record<Kanal, string> = {
|
||||
MAIL: "E-Mail",
|
||||
TELEGRAM: "Telegram",
|
||||
NTFY: "ntfy (Push)",
|
||||
WEBPUSH: "Browser-Push",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ob ein Kanal die Wehr verlässt.
|
||||
*
|
||||
* Für die Oberfläche: neben einem externen Kanal steht, dass dorthin nur
|
||||
* Hinweise ohne Personenbezug gehen. Wer das nicht weiß, trägt irgendwann
|
||||
* Namen in eine Vorlage ein, die bei Telegram landet.
|
||||
*/
|
||||
export const KANAL_EXTERN: Record<Kanal, boolean> = {
|
||||
MAIL: false,
|
||||
TELEGRAM: true,
|
||||
NTFY: false,
|
||||
WEBPUSH: true,
|
||||
};
|
||||
|
||||
/** Welche Platzhalter es je Ereignis gibt — die Oberfläche zeigt sie an. */
|
||||
export const PLATZHALTER: Record<EreignisTyp, string[]> = {
|
||||
PRUEFUNG_FAELLIG: ["wehr", "anzahl", "geraet", "inventarnummer", "faelligAm", "tage"],
|
||||
MANGEL_GEMELDET: ["wehr", "geraet", "inventarnummer", "beschreibung", "prioritaet", "melder"],
|
||||
QUALIFIKATION_LAEUFT_AB: ["wehr", "person", "qualifikation", "gueltigBis", "tage"],
|
||||
TERMIN_NEU: ["wehr", "titel", "art", "datum", "uhrzeit", "ort", "leiter"],
|
||||
TERMIN_ERINNERUNG: ["wehr", "titel", "art", "datum", "uhrzeit", "ort", "tage"],
|
||||
TERMIN_ABGESAGT: ["wehr", "titel", "datum", "uhrzeit", "grund"],
|
||||
EINSATZBERICHT_FREIGEGEBEN: ["wehr", "einsatznummer", "stichwort", "datum"],
|
||||
};
|
||||
|
||||
interface Standardvorlage {
|
||||
betreff: string;
|
||||
text: string;
|
||||
/** Kurzfassung für Kanäle, die nach draußen gehen. */
|
||||
kurz: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eingebauten Texte.
|
||||
*
|
||||
* Sie sind bewusst schlicht: eine Benachrichtigung wird auf einem Sperrbildschirm
|
||||
* gelesen, nicht am Schreibtisch. Was zu tun ist, steht im ersten Satz.
|
||||
*/
|
||||
export const STANDARD: Record<EreignisTyp, Standardvorlage> = {
|
||||
PRUEFUNG_FAELLIG: {
|
||||
betreff: "{anzahl} Prüfung(en) fällig",
|
||||
text:
|
||||
"Bei der {wehr} stehen {anzahl} Prüfungen an.\n\n" +
|
||||
"Nächste: {geraet} ({inventarnummer}), fällig am {faelligAm} — das ist in {tage} Tagen.\n\n" +
|
||||
"Die vollständige Liste steht in FLORIAN unter Prüfungen.",
|
||||
kurz: "{anzahl} Prüfungen fällig, die nächste in {tage} Tagen.",
|
||||
},
|
||||
MANGEL_GEMELDET: {
|
||||
betreff: "Mangel gemeldet: {geraet}",
|
||||
text:
|
||||
"{melder} hat einen Mangel gemeldet.\n\n" +
|
||||
"Gerät: {geraet} ({inventarnummer})\n" +
|
||||
"Dringlichkeit: {prioritaet}\n\n" +
|
||||
"{beschreibung}",
|
||||
kurz: "Neuer Mangel gemeldet, Dringlichkeit {prioritaet}.",
|
||||
},
|
||||
QUALIFIKATION_LAEUFT_AB: {
|
||||
betreff: "{qualifikation} läuft ab",
|
||||
text:
|
||||
"Die Qualifikation {qualifikation} von {person} ist noch bis {gueltigBis} gültig — " +
|
||||
"das sind {tage} Tage.\n\n" +
|
||||
"Ohne gültigen Nachweis ist ein Einsatz in dieser Funktion nicht möglich.",
|
||||
kurz: "Eine Qualifikation läuft in {tage} Tagen ab.",
|
||||
},
|
||||
TERMIN_NEU: {
|
||||
betreff: "Neuer Termin: {titel}",
|
||||
text:
|
||||
"{titel} ({art})\n" +
|
||||
"{datum} um {uhrzeit} Uhr\n" +
|
||||
"Ort: {ort}\n" +
|
||||
"Leitung: {leiter}\n\n" +
|
||||
"Zu- und Absagen in FLORIAN unter Termine.",
|
||||
kurz: "Neuer Termin am {datum}: {titel}",
|
||||
},
|
||||
TERMIN_ERINNERUNG: {
|
||||
betreff: "Erinnerung: {titel} am {datum}",
|
||||
text:
|
||||
"{titel} ({art}) findet in {tage} Tagen statt.\n\n" +
|
||||
"{datum} um {uhrzeit} Uhr, {ort}.",
|
||||
kurz: "In {tage} Tagen: {titel}",
|
||||
},
|
||||
TERMIN_ABGESAGT: {
|
||||
betreff: "Abgesagt: {titel} am {datum}",
|
||||
text: "{titel} am {datum} um {uhrzeit} Uhr fällt aus.\n\nGrund: {grund}",
|
||||
kurz: "Termin am {datum} fällt aus.",
|
||||
},
|
||||
EINSATZBERICHT_FREIGEGEBEN: {
|
||||
betreff: "Einsatzbericht {einsatznummer} freigegeben",
|
||||
text:
|
||||
"Der Bericht zum Einsatz {einsatznummer} ({stichwort}, {datum}) ist freigegeben " +
|
||||
"und damit festgeschrieben.\n\n" +
|
||||
"Er steht in FLORIAN unter Einsätze zum Herunterladen.",
|
||||
kurz: "Einsatzbericht {einsatznummer} ist freigegeben.",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Platzhalter ersetzen.
|
||||
*
|
||||
* Unbekannte Platzhalter bleiben stehen. Das sieht in einer Nachricht seltsam
|
||||
* aus — und genau deshalb fällt es auf und wird korrigiert. Stillschweigend zu
|
||||
* löschen hieße, eine Lücke zu hinterlassen, die niemand bemerkt.
|
||||
*/
|
||||
export function einsetzen(vorlage: string, werte: Record<string, string | number | null>): string {
|
||||
return vorlage.replace(/\{([a-zA-ZäöüÄÖÜß]+)\}/g, (treffer, name: string) => {
|
||||
const wert = werte[name];
|
||||
if (wert === undefined) return treffer;
|
||||
if (wert === null) return "—";
|
||||
return String(wert);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Den fertigen Text für ein Ereignis bauen.
|
||||
*
|
||||
* Die Vorlage aus der Datenbank gewinnt; fehlt sie, greift der Standardtext.
|
||||
* Für Kanäle, die nach draußen gehen, wird die Kurzfassung genommen — ohne
|
||||
* Namen, ohne Adresse, ohne Einsatzdetails.
|
||||
*/
|
||||
export function nachrichtBauen(angaben: {
|
||||
ereignis: EreignisTyp;
|
||||
kanal: Kanal;
|
||||
werte: Record<string, string | number | null>;
|
||||
vorlage?: { betreff: string | null; text: string } | null;
|
||||
}): { betreff: string; text: string } {
|
||||
const { ereignis, kanal, werte, vorlage } = angaben;
|
||||
const standard = STANDARD[ereignis];
|
||||
|
||||
if (vorlage) {
|
||||
return {
|
||||
betreff: einsetzen(vorlage.betreff ?? standard.betreff, werte),
|
||||
text: einsetzen(vorlage.text, werte),
|
||||
};
|
||||
}
|
||||
|
||||
const roh = KANAL_EXTERN[kanal] ? standard.kurz : standard.text;
|
||||
|
||||
return {
|
||||
betreff: einsetzen(standard.betreff, werte),
|
||||
text: einsetzen(roh, werte),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft eine Vorlage auf Platzhalter, die es für dieses Ereignis nicht gibt.
|
||||
*
|
||||
* Wird beim Speichern aufgerufen. Ein `{name}` in einer Vorlage für „Prüfung
|
||||
* fällig" wäre nie ersetzt worden — besser, das steht schon beim Eintippen da.
|
||||
*/
|
||||
export function unbekanntePlatzhalter(ereignis: EreignisTyp, text: string): string[] {
|
||||
const erlaubt = new Set(PLATZHALTER[ereignis]);
|
||||
const gefunden = [...text.matchAll(/\{([a-zA-ZäöüÄÖÜß]+)\}/g)].map((treffer) => treffer[1]!);
|
||||
|
||||
return [...new Set(gefunden.filter((name) => !erlaubt.has(name)))];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import "server-only";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Der digitale Dienstausweis.
|
||||
*
|
||||
* Ein QR-Code auf dem Handy, den jemand vorzeigt: an der Absperrung, beim
|
||||
* Lehrgang, bei einer Kontrolle. Wer ihn scannt, landet auf einer Prüfseite und
|
||||
* sieht Name, Wehr, Funktion, Foto und die gültigen Qualifikationen — **ohne
|
||||
* sich anzumelden**, denn wer prüft, hat keinen Zugang zu FLORIAN.
|
||||
*
|
||||
* Genau daraus folgt der Aufbau, und er ist derselbe wie bei der Anmeldemarke
|
||||
* aus Phase 4 — mit einem wichtigen Unterschied:
|
||||
*
|
||||
* * **Signiert, nicht gespeichert.** HMAC über Person, Wehr und Zeitstempel
|
||||
* mit AUTH_SECRET. Keine Tabelle, kein Aufräumjob.
|
||||
* * **Kurz gültig.** Die Anmeldemarke gilt eine Minute, weil sie eine
|
||||
* Anmeldung ist. Der Ausweis gilt fünf Minuten: er wird vorgezeigt,
|
||||
* abgelesen und weggesteckt, und der Prüfende braucht Zeit zum Scannen.
|
||||
* Ein abfotografierter Code ist danach wertlos.
|
||||
* * **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein.
|
||||
* Wer den Code hat, bekommt keinen Zugang zu einem einzigen Datensatz —
|
||||
* er sieht dieselben Angaben, die auf einem Ausweis aus Papier stehen.
|
||||
*
|
||||
* Was **nicht** darauf steht: Anschrift, Geburtsdatum, Telefonnummer,
|
||||
* Mitgliedsnummer. Ein Ausweis weist aus; er ist keine Personalakte.
|
||||
*/
|
||||
|
||||
/** Wie lange ein Ausweiscode gilt. Lang genug zum Vorzeigen, kurz genug fürs Foto. */
|
||||
export const AUSWEIS_GUELTIG_SEKUNDEN = 300;
|
||||
|
||||
const TRENNER = ".";
|
||||
const ZWECK = "ausweis";
|
||||
|
||||
function signieren(nutzlast: string): string {
|
||||
return createHmac("sha256", env.AUTH_SECRET).update(nutzlast).digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Ausweiscode erzeugen.
|
||||
*
|
||||
* Aufbau: `ausweis.<personId>.<organisationId>.<zeitstempel>.<signatur>`
|
||||
*
|
||||
* Der Zweck steht vorne mit drin, damit ein Ausweiscode nie als Anmeldemarke
|
||||
* durchgeht und umgekehrt — beide sind mit demselben Geheimnis signiert.
|
||||
*/
|
||||
export function ausweisErzeugen(personId: string, organisationId: string): string {
|
||||
const nutzlast = [ZWECK, personId, organisationId, String(Date.now())].join(TRENNER);
|
||||
return `${nutzlast}${TRENNER}${signieren(nutzlast)}`;
|
||||
}
|
||||
|
||||
export type AusweisFehler = "ungueltig" | "abgelaufen" | "verfaelscht";
|
||||
|
||||
export const AUSWEIS_FEHLER_TEXT: Record<AusweisFehler, string> = {
|
||||
ungueltig: "Dieser Code ist kein Dienstausweis.",
|
||||
verfaelscht: "Dieser Code stammt nicht von dieser Wehr.",
|
||||
abgelaufen:
|
||||
"Der Code ist abgelaufen. Er gilt fünf Minuten — bitte auf dem Handy neu anzeigen lassen.",
|
||||
};
|
||||
|
||||
/** Einen Ausweiscode prüfen. */
|
||||
export function ausweisPruefen(
|
||||
code: string,
|
||||
): { personId: string; organisationId: string; erzeugtAm: Date } | { fehler: AusweisFehler } {
|
||||
const teile = code.split(TRENNER);
|
||||
if (teile.length !== 5 || teile[0] !== ZWECK) return { fehler: "ungueltig" };
|
||||
|
||||
const [zweck, personId, organisationId, zeitstempel, signatur] = teile as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
|
||||
const erwartet = signieren([zweck, personId, organisationId, zeitstempel].join(TRENNER));
|
||||
|
||||
const a = Buffer.from(signatur);
|
||||
const b = Buffer.from(erwartet);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return { fehler: "verfaelscht" };
|
||||
|
||||
const alter = Date.now() - Number(zeitstempel);
|
||||
if (!Number.isFinite(alter) || alter < 0 || alter > AUSWEIS_GUELTIG_SEKUNDEN * 1000) {
|
||||
return { fehler: "abgelaufen" };
|
||||
}
|
||||
|
||||
return { personId, organisationId, erzeugtAm: new Date(Number(zeitstempel)) };
|
||||
}
|
||||
@@ -101,6 +101,48 @@ const schema = z.object({
|
||||
TELEGRAM_BOT_TOKEN: z.string().optional(),
|
||||
TELEGRAM_CHAT_ID: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Push über einen selbst betriebenen ntfy (Phase 7).
|
||||
*
|
||||
* Steht der ntfy im eigenen Netz, verlässt keine Nachricht das Gerätehaus —
|
||||
* deshalb gilt er als interner Kanal und bekommt die ausführlichen Texte.
|
||||
* Wer stattdessen das öffentliche ntfy.sh einträgt, bekommt eine Warnung in
|
||||
* der Oberfläche: dann ist es ein externer Dienst wie jeder andere.
|
||||
*/
|
||||
NTFY_ENABLED: boolAusText(false),
|
||||
NTFY_URL: z.string().url().optional(),
|
||||
/// Nur nötig, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat.
|
||||
NTFY_TOKEN: z.string().optional(),
|
||||
NTFY_TIMEOUT: zahl(10),
|
||||
|
||||
/**
|
||||
* Web-Push in den Browser (Phase 7).
|
||||
*
|
||||
* Die VAPID-Schlüssel einmal mit `npm run vapid` erzeugen und hier eintragen.
|
||||
* Sie gehören ins Backup: ein neuer öffentlicher Schlüssel macht alle
|
||||
* bestehenden Push-Abos wertlos, jeder müsste Push neu erlauben.
|
||||
*/
|
||||
WEBPUSH_ENABLED: boolAusText(false),
|
||||
WEBPUSH_PUBLIC_KEY: z.string().optional(),
|
||||
WEBPUSH_PRIVATE_KEY: z.string().optional(),
|
||||
/// Kontaktweg für den Push-Dienst, "mailto:..." oder eine Adresse.
|
||||
WEBPUSH_KONTAKT: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Benachrichtigungen und Termine (Phase 7).
|
||||
*
|
||||
* Wann der Worker die Erinnerungen versendet. Bewusst am frühen Morgen und
|
||||
* nicht nachts: eine Push-Meldung um drei Uhr weckt Leute, die am nächsten
|
||||
* Tag arbeiten müssen.
|
||||
*/
|
||||
BENACHRICHTIGUNG_LAUF: z.string().default("0 7 * * *"),
|
||||
/// Wie viele Tage vor einem Termin erinnert wird (kommagetrennt, absteigend).
|
||||
TERMIN_ERINNERUNG_TAGE: z.string().default("7,1"),
|
||||
/// Wie oft eine fehlgeschlagene Zustellung wiederholt wird.
|
||||
BENACHRICHTIGUNG_VERSUCHE: zahl(3),
|
||||
/// Wie lange zugestellte Benachrichtigungen aufgehoben werden.
|
||||
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: zahl(90),
|
||||
|
||||
LLM_ENABLED: boolAusText(false),
|
||||
LLM_BASE_URL: z.string().optional(),
|
||||
LLM_API_KEY: z.string().optional(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
@@ -9,8 +10,12 @@ import { env } from "@/lib/env";
|
||||
* nicht gesetzt, landet die Nachricht im Log statt beim Empfänger — für
|
||||
* Entwicklung und Testbetrieb genau richtig.
|
||||
*
|
||||
* Die tatsächliche SMTP-Anbindung kommt in Phase 7 (Benachrichtigungen). Bis
|
||||
* dahin steht hier bewusst nur der Vertrag.
|
||||
* Seit Phase 7 steht dahinter echter SMTP-Versand über nodemailer. „Extern" ist
|
||||
* dabei relativ zu verstehen: der Mailserver einer Wehr ist in aller Regel der
|
||||
* eigene oder der der Gemeinde. Deshalb ist E-Mail der Kanal, über den die
|
||||
* **ausführlichen** Benachrichtigungen gehen — mit Namen, Gerätebezeichnungen
|
||||
* und Einsatznummern. Alles, was über fremde Server läuft (Telegram,
|
||||
* Web-Push), bekommt nur die Kurzfassung.
|
||||
*/
|
||||
|
||||
export interface Nachricht {
|
||||
@@ -42,21 +47,76 @@ class ProtokollVersand implements MailVersand {
|
||||
}
|
||||
}
|
||||
|
||||
/** Platzhalter für den echten SMTP-Versand aus Phase 7. */
|
||||
/**
|
||||
* Echter Versand über SMTP.
|
||||
*
|
||||
* Der Transport wird einmal gebaut und behalten: nodemailer hält damit einen
|
||||
* Pool offener Verbindungen, statt für jede Erinnerung neu zu verbinden und
|
||||
* TLS auszuhandeln. Bei einem nächtlichen Lauf mit vierzig Nachrichten macht
|
||||
* das den Unterschied zwischen Sekunden und Minuten.
|
||||
*/
|
||||
class SmtpVersand implements MailVersand {
|
||||
private transport: Transporter | null = null;
|
||||
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.MAIL_HOST && env.MAIL_FROM);
|
||||
}
|
||||
|
||||
private hole(): Transporter {
|
||||
if (!this.transport) {
|
||||
this.transport = nodemailer.createTransport({
|
||||
host: env.MAIL_HOST,
|
||||
port: env.MAIL_PORT,
|
||||
// true = SMTPS auf 465, false = STARTTLS auf 587. Beides ist
|
||||
// verschlüsselt; der Unterschied ist nur, wann.
|
||||
secure: env.MAIL_SECURE,
|
||||
auth:
|
||||
env.MAIL_USER && env.MAIL_PASSWORD
|
||||
? { user: env.MAIL_USER, pass: env.MAIL_PASSWORD }
|
||||
: undefined,
|
||||
pool: true,
|
||||
maxConnections: 3,
|
||||
// Der Mailserver im Gerätehaus ist gelegentlich langsam. Zwanzig
|
||||
// Sekunden sind großzügig, aber nicht endlos — ein hängender Versand
|
||||
// blockiert sonst den Worker.
|
||||
connectionTimeout: 20_000,
|
||||
greetingTimeout: 20_000,
|
||||
socketTimeout: 30_000,
|
||||
});
|
||||
}
|
||||
return this.transport;
|
||||
}
|
||||
|
||||
async senden(nachricht: Nachricht) {
|
||||
const empfaenger = Array.isArray(nachricht.an) ? nachricht.an.join(", ") : nachricht.an;
|
||||
console.warn(
|
||||
`[mail] SMTP-Versand ist erst ab Phase 7 umgesetzt. an=${empfaenger} betreff="${nachricht.betreff}"`,
|
||||
);
|
||||
return {
|
||||
erfolgreich: false,
|
||||
fehler: "SMTP-Versand noch nicht umgesetzt (Phase 7).",
|
||||
};
|
||||
if (!this.verfuegbar()) {
|
||||
return {
|
||||
erfolgreich: false,
|
||||
fehler: "MAIL_HOST oder MAIL_FROM fehlt — SMTP ist nicht vollständig konfiguriert.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const ergebnis = await this.hole().sendMail({
|
||||
from: env.MAIL_FROM,
|
||||
to: nachricht.an,
|
||||
replyTo: nachricht.antwortAn,
|
||||
subject: nachricht.betreff,
|
||||
text: nachricht.text,
|
||||
html: nachricht.html,
|
||||
attachments: nachricht.anhaenge?.map((anhang) => ({
|
||||
filename: anhang.dateiname,
|
||||
content: anhang.inhalt,
|
||||
contentType: anhang.mimeTyp,
|
||||
})),
|
||||
});
|
||||
|
||||
return { erfolgreich: true, kennung: ergebnis.messageId };
|
||||
} catch (fehler) {
|
||||
// Der Text landet in der Benachrichtigungszeile und wird von einem
|
||||
// Menschen gelesen — deshalb die Meldung des Servers unverändert
|
||||
// durchreichen statt sie zu „Fehler beim Versand" einzudampfen.
|
||||
return { erfolgreich: false, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import "server-only";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Push auf das Handy über ntfy.
|
||||
*
|
||||
* ntfy ist ein winziger Push-Dienst, den man selbst betreibt: ein Container,
|
||||
* eine App im Store, fertig. Man abonniert ein „Thema" (einen frei gewählten
|
||||
* Namen) und bekommt Nachrichten darauf.
|
||||
*
|
||||
* Für eine Feuerwehr ist das der interessanteste der Push-Wege:
|
||||
*
|
||||
* * **Er bleibt im Haus.** Läuft der Server im Gerätehaus, verlässt keine
|
||||
* Nachricht das eigene Netz. Deshalb gilt ntfy hier — anders als Telegram —
|
||||
* als interner Kanal und bekommt die ausführlichen Texte.
|
||||
* * **Er braucht kein Konto.** Kein Google-Login, kein Telefonbuchabgleich,
|
||||
* keine Nummer. Wer mitlesen will, scannt einen QR-Code mit dem Thema.
|
||||
* * **Er funktioniert auf beiden Systemen.** Android bekommt echte Pushes,
|
||||
* iOS über den Umweg des öffentlichen ntfy.sh — wer das nicht will,
|
||||
* benutzt auf iOS die E-Mail.
|
||||
*
|
||||
* Der Preis: ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest
|
||||
* mit. Deshalb erzeugt FLORIAN Themennamen mit Zufallsanteil und legt sie
|
||||
* nicht offen — und deshalb kann zusätzlich ein Zugangstoken hinterlegt
|
||||
* werden, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat.
|
||||
*/
|
||||
|
||||
export type Dringlichkeit = "min" | "low" | "default" | "high" | "urgent";
|
||||
|
||||
export interface NtfyNachricht {
|
||||
/** Das Thema, auf das gesendet wird. */
|
||||
thema: string;
|
||||
titel: string;
|
||||
text: string;
|
||||
dringlichkeit?: Dringlichkeit;
|
||||
/** Emoji-Kurznamen, z. B. "fire_engine" — die App zeigt sie vor dem Titel. */
|
||||
symbole?: string[];
|
||||
/** Ein Verweis zurück in FLORIAN, den die App als Schaltfläche anzeigt. */
|
||||
verweis?: string;
|
||||
}
|
||||
|
||||
export interface NtfyVersand {
|
||||
senden(nachricht: NtfyNachricht): Promise<{ erfolgreich: boolean; fehler?: string }>;
|
||||
verfuegbar(): boolean;
|
||||
}
|
||||
|
||||
class ProtokollNtfy implements NtfyVersand {
|
||||
verfuegbar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async senden(nachricht: NtfyNachricht) {
|
||||
console.info(
|
||||
`[ntfy] (nicht versendet, NTFY_ENABLED=false) thema=${nachricht.thema} titel="${nachricht.titel}"`,
|
||||
);
|
||||
return { erfolgreich: true };
|
||||
}
|
||||
}
|
||||
|
||||
class EchtesNtfy implements NtfyVersand {
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.NTFY_URL);
|
||||
}
|
||||
|
||||
async senden(nachricht: NtfyNachricht) {
|
||||
if (!this.verfuegbar()) {
|
||||
return { erfolgreich: false, fehler: "NTFY_URL ist nicht gesetzt." };
|
||||
}
|
||||
|
||||
const kopf: Record<string, string> = {
|
||||
// ntfy liest Titel und Zusatzangaben aus Kopfzeilen. Sie müssen
|
||||
// ASCII sein — Umlaute im Titel würden den Server sonst abweisen,
|
||||
// deshalb wird der Titel in RFC-2047 verpackt.
|
||||
Title: alsAsciiKopf(nachricht.titel),
|
||||
Priority: nachricht.dringlichkeit ?? "default",
|
||||
};
|
||||
|
||||
if (nachricht.symbole?.length) kopf.Tags = nachricht.symbole.join(",");
|
||||
if (nachricht.verweis) kopf.Click = nachricht.verweis;
|
||||
if (env.NTFY_TOKEN) kopf.Authorization = `Bearer ${env.NTFY_TOKEN}`;
|
||||
|
||||
try {
|
||||
const antwort = await fetch(`${env.NTFY_URL!.replace(/\/+$/, "")}/${nachricht.thema}`, {
|
||||
method: "POST",
|
||||
headers: kopf,
|
||||
body: nachricht.text,
|
||||
signal: AbortSignal.timeout(env.NTFY_TIMEOUT * 1000),
|
||||
});
|
||||
|
||||
if (!antwort.ok) {
|
||||
return { erfolgreich: false, fehler: `ntfy antwortete mit ${antwort.status}` };
|
||||
}
|
||||
return { erfolgreich: true };
|
||||
} catch (fehler) {
|
||||
return { erfolgreich: false, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Text so verpacken, dass er in eine HTTP-Kopfzeile passt.
|
||||
*
|
||||
* Kopfzeilen sind ASCII. „Prüfung fällig" wäre ohne das hier ein abgewiesener
|
||||
* Aufruf oder — schlimmer — ein Titel voller Fragezeichen.
|
||||
*/
|
||||
function alsAsciiKopf(text: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^[\x00-\x7F]*$/.test(text)) return text;
|
||||
return `=?UTF-8?B?${Buffer.from(text, "utf8").toString("base64")}?=`;
|
||||
}
|
||||
|
||||
let cache: NtfyVersand | null = null;
|
||||
|
||||
export function ntfy(): NtfyVersand {
|
||||
if (!cache) {
|
||||
cache = env.NTFY_ENABLED ? new EchtesNtfy() : new ProtokollNtfy();
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Nur für Tests. */
|
||||
export function _ntfyErsetzen(ersatz: NtfyVersand | null): void {
|
||||
cache = ersatz;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Themennamen für eine Person erzeugen.
|
||||
*
|
||||
* Der Name ist das Geheimnis — deshalb ist er nicht ableitbar. „florian-müller"
|
||||
* würde jeder erraten, der den Namen kennt.
|
||||
*/
|
||||
export function themaVorschlagen(praefix = "florian"): string {
|
||||
const zufall = Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString("base64url");
|
||||
return `${praefix}-${zufall}`;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import "server-only";
|
||||
import webpush from "web-push";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Push in den Browser (Web-Push, RFC 8030).
|
||||
*
|
||||
* Der Weg, der ohne App auskommt: der Browser meldet sich beim Push-Dienst
|
||||
* seines Herstellers an (Mozilla, Google, Apple) und gibt FLORIAN eine
|
||||
* Adresse, an die geschickt werden darf. Ein Abo ist an genau einen Browser
|
||||
* auf genau einem Gerät gebunden.
|
||||
*
|
||||
* **Warum das ein externer Kanal ist:** die Nachricht läuft über den Push-Dienst
|
||||
* des Browserherstellers. Sie ist zwar Ende-zu-Ende verschlüsselt — der Dienst
|
||||
* sieht den Inhalt nicht, nur dass etwas kam und wie groß es war —, aber sie
|
||||
* verlässt das Netz der Wehr. Deshalb steht in einem Web-Push die Kurzfassung:
|
||||
* „3 Prüfungen fällig", nicht welche.
|
||||
*
|
||||
* **VAPID:** die Schlüssel weisen FLORIAN gegenüber dem Push-Dienst aus. Sie
|
||||
* werden einmal erzeugt (`npm run vapid`) und in die Umgebung gelegt. Wechselt
|
||||
* der öffentliche Schlüssel, sind alle bestehenden Abos wertlos — die Anwender
|
||||
* müssten Push neu erlauben. Deshalb gehören sie ins Backup.
|
||||
*/
|
||||
|
||||
export interface WebPushAbo {
|
||||
endpunkt: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
export interface WebPushNachricht {
|
||||
titel: string;
|
||||
text: string;
|
||||
/** Wohin es geht, wenn jemand die Meldung antippt. */
|
||||
verweis?: string;
|
||||
/**
|
||||
* Gleiches Kennzeichen ersetzt eine noch nicht gelesene Meldung, statt eine
|
||||
* zweite daneben zu legen. Sonst stapeln sich nachts fünf Erinnerungen an
|
||||
* denselben Termin.
|
||||
*/
|
||||
kennzeichen?: string;
|
||||
}
|
||||
|
||||
export interface WebPushVersand {
|
||||
senden(
|
||||
abo: WebPushAbo,
|
||||
nachricht: WebPushNachricht,
|
||||
): Promise<{ erfolgreich: boolean; fehler?: string; abgelaufen?: boolean }>;
|
||||
verfuegbar(): boolean;
|
||||
/** Der öffentliche Schlüssel — den braucht der Browser zum Anmelden. */
|
||||
oeffentlicherSchluessel(): string | null;
|
||||
}
|
||||
|
||||
class ProtokollWebPush implements WebPushVersand {
|
||||
verfuegbar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
oeffentlicherSchluessel(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async senden(_abo: WebPushAbo, nachricht: WebPushNachricht) {
|
||||
console.info(
|
||||
`[webpush] (nicht versendet, WEBPUSH_ENABLED=false) titel="${nachricht.titel}"`,
|
||||
);
|
||||
return { erfolgreich: true };
|
||||
}
|
||||
}
|
||||
|
||||
class EchtesWebPush implements WebPushVersand {
|
||||
private eingerichtet = false;
|
||||
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.WEBPUSH_PUBLIC_KEY && env.WEBPUSH_PRIVATE_KEY);
|
||||
}
|
||||
|
||||
oeffentlicherSchluessel(): string | null {
|
||||
return env.WEBPUSH_PUBLIC_KEY ?? null;
|
||||
}
|
||||
|
||||
private einrichten(): void {
|
||||
if (this.eingerichtet) return;
|
||||
webpush.setVapidDetails(
|
||||
// Ein Kontaktweg ist Pflicht — daran meldet sich der Push-Dienst, wenn
|
||||
// etwas grundsätzlich schiefläuft.
|
||||
env.WEBPUSH_KONTAKT ?? env.APP_URL,
|
||||
env.WEBPUSH_PUBLIC_KEY!,
|
||||
env.WEBPUSH_PRIVATE_KEY!,
|
||||
);
|
||||
this.eingerichtet = true;
|
||||
}
|
||||
|
||||
async senden(abo: WebPushAbo, nachricht: WebPushNachricht) {
|
||||
if (!this.verfuegbar()) {
|
||||
return { erfolgreich: false, fehler: "VAPID-Schlüssel fehlen (WEBPUSH_PUBLIC_KEY/PRIVATE_KEY)." };
|
||||
}
|
||||
|
||||
this.einrichten();
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: abo.endpunkt, keys: { p256dh: abo.p256dh, auth: abo.auth } },
|
||||
JSON.stringify({
|
||||
titel: nachricht.titel,
|
||||
text: nachricht.text,
|
||||
verweis: nachricht.verweis,
|
||||
kennzeichen: nachricht.kennzeichen,
|
||||
}),
|
||||
{ TTL: 12 * 60 * 60 },
|
||||
);
|
||||
return { erfolgreich: true };
|
||||
} catch (fehler) {
|
||||
// 404 und 410 heißen: dieses Abo gibt es nicht mehr (Browser
|
||||
// deinstalliert, Berechtigung entzogen, Profil gelöscht). Das ist kein
|
||||
// Fehler, sondern eine Aufforderung zum Aufräumen — der Aufrufer
|
||||
// deaktiviert den Zustellkanal daraufhin.
|
||||
const status =
|
||||
typeof fehler === "object" && fehler !== null && "statusCode" in fehler
|
||||
? Number((fehler as { statusCode: unknown }).statusCode)
|
||||
: 0;
|
||||
|
||||
if (status === 404 || status === 410) {
|
||||
return { erfolgreich: false, abgelaufen: true, fehler: "Das Push-Abo gilt nicht mehr." };
|
||||
}
|
||||
|
||||
return { erfolgreich: false, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cache: WebPushVersand | null = null;
|
||||
|
||||
export function webPush(): WebPushVersand {
|
||||
if (!cache) {
|
||||
cache = env.WEBPUSH_ENABLED ? new EchtesWebPush() : new ProtokollWebPush();
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Nur für Tests. */
|
||||
export function _webPushErsetzen(ersatz: WebPushVersand | null): void {
|
||||
cache = ersatz;
|
||||
}
|
||||
|
||||
/** Ein Schlüsselpaar erzeugen — für das Einrichtungsskript. */
|
||||
export function schluesselpaarErzeugen(): { oeffentlich: string; privat: string } {
|
||||
const paar = webpush.generateVAPIDKeys();
|
||||
return { oeffentlich: paar.publicKey, privat: paar.privateKey };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Dienstgrad } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Klartext zu Aufzählungswerten der Stammdaten.
|
||||
*
|
||||
* Die Dienstgradliste stand bis Phase 7 zweimal fast gleich in zwei Seiten.
|
||||
* Mit dem Dienstausweis wäre sie ein drittes Mal dazugekommen — und spätestens
|
||||
* dann steht irgendwo „Löschmeister" und woanders „Loeschmeister".
|
||||
*
|
||||
* Die Bezeichnungen folgen der bayerischen Feuerwehr-Dienstgradordnung. In
|
||||
* anderen Bundesländern heißen die Grade teils anders; das ist einer der
|
||||
* Punkte, die eine Wehr später einstellen können soll.
|
||||
*/
|
||||
export const DIENSTGRAD_TEXT: Record<Dienstgrad, string> = {
|
||||
FEUERWEHRANWAERTER: "Feuerwehranwärter",
|
||||
FEUERWEHRMANN: "Feuerwehrmann",
|
||||
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
|
||||
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
|
||||
LOESCHMEISTER: "Löschmeister",
|
||||
OBERLOESCHMEISTER: "Oberlöschmeister",
|
||||
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
|
||||
BRANDMEISTER: "Brandmeister",
|
||||
OBERBRANDMEISTER: "Oberbrandmeister",
|
||||
HAUPTBRANDMEISTER: "Hauptbrandmeister",
|
||||
KEIN: "—",
|
||||
};
|
||||
|
||||
/** Der Dienstgrad als Text, oder nichts, wenn keiner geführt wird. */
|
||||
export function dienstgradText(grad: Dienstgrad | null | undefined): string | null {
|
||||
if (!grad || grad === "KEIN") return null;
|
||||
return DIENSTGRAD_TEXT[grad] ?? grad;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { TerminArt } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Kalenderdatei nach RFC 5545 (iCalendar).
|
||||
*
|
||||
* Damit abonniert jemand die Termine der Wehr im Handy oder im
|
||||
* Mailprogramm — und sieht sie dort neben allem anderen, statt in FLORIAN
|
||||
* nachsehen zu müssen.
|
||||
*
|
||||
* Selbst geschrieben statt über eine Bibliothek: das Format ist eine Liste von
|
||||
* Zeilen, die interessanten Teile sind die Zeitzone und das Umbrechen langer
|
||||
* Zeilen. Beides steht hier in zwanzig Zeilen und ist damit lesbar — eine
|
||||
* Abhängigkeit für ein Textformat wäre in einer Anwendung ohne Netzzugang der
|
||||
* schlechtere Tausch.
|
||||
*
|
||||
* Bewusst **kein CalDAV**: das ist ein Schreib-Lese-Protokoll mit
|
||||
* Rechteverwaltung und Konfliktauflösung. Gebraucht wird hier nur Lesen, und
|
||||
* dafür genügt eine Adresse, die eine ICS-Datei ausliefert. Jeder Kalender
|
||||
* kann das; CalDAV wäre ein Vielfaches an Aufwand für dieselbe Wirkung.
|
||||
*/
|
||||
|
||||
export interface IcsTermin {
|
||||
id: string;
|
||||
titel: string;
|
||||
beschreibung?: string | null;
|
||||
ort?: string | null;
|
||||
beginn: Date;
|
||||
ende?: Date | null;
|
||||
ganztaegig: boolean;
|
||||
art: TerminArt;
|
||||
abgesagt: boolean;
|
||||
geaendertAm: Date;
|
||||
/** Zu- oder Absage der Person, für die der Kalender erzeugt wird. */
|
||||
eigenerStand?: "ZUGESAGT" | "ABGESAGT" | "WARTELISTE" | null;
|
||||
}
|
||||
|
||||
const ART_TEXT: Record<TerminArt, string> = {
|
||||
DIENST: "Dienst",
|
||||
UEBUNG: "Übung",
|
||||
LEHRGANG: "Lehrgang",
|
||||
ARBEITSDIENST: "Arbeitsdienst",
|
||||
VERANSTALTUNG: "Veranstaltung",
|
||||
SITZUNG: "Sitzung",
|
||||
SONSTIGES: "Termin",
|
||||
};
|
||||
|
||||
/** Zeitstempel in UTC, wie es das Format verlangt: 20260904T183000Z */
|
||||
function alsZeitstempel(datum: Date): string {
|
||||
return datum.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
||||
}
|
||||
|
||||
/** Nur das Datum — für ganztägige Termine: 20260904 */
|
||||
function alsDatum(datum: Date): string {
|
||||
return alsZeitstempel(datum).slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonderzeichen maskieren.
|
||||
*
|
||||
* Komma, Semikolon und Backslash haben im Format eine Bedeutung, Zeilenumbrüche
|
||||
* werden zu `\n`. Ohne das zerlegt ein Komma im Titel den Eintrag.
|
||||
*/
|
||||
function maskieren(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lange Zeilen umbrechen.
|
||||
*
|
||||
* RFC 5545 erlaubt 75 Oktette je Zeile; längere werden mit einem Leerzeichen
|
||||
* am Zeilenanfang fortgesetzt. Manche Kalender sind streng damit.
|
||||
*/
|
||||
function umbrechen(zeile: string): string {
|
||||
if (zeile.length <= 73) return zeile;
|
||||
|
||||
const teile: string[] = [zeile.slice(0, 73)];
|
||||
let rest = zeile.slice(73);
|
||||
|
||||
while (rest.length > 72) {
|
||||
teile.push(` ${rest.slice(0, 72)}`);
|
||||
rest = rest.slice(72);
|
||||
}
|
||||
if (rest.length > 0) teile.push(` ${rest}`);
|
||||
|
||||
return teile.join("\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Kalenderdatei bauen.
|
||||
*
|
||||
* @param name Anzeigename des Kalenders im Programm des Anwenders
|
||||
* @param termine Was hineinkommt
|
||||
* @param adresse Basisadresse für die Verweise zurück in FLORIAN
|
||||
*/
|
||||
export function icsErzeugen(angaben: {
|
||||
name: string;
|
||||
termine: IcsTermin[];
|
||||
adresse: string;
|
||||
}): string {
|
||||
const zeilen: string[] = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//FLORIAN//Feuerwehr-Verwaltung//DE",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
`X-WR-CALNAME:${maskieren(angaben.name)}`,
|
||||
"X-WR-TIMEZONE:Europe/Berlin",
|
||||
// Wie oft der Kalender nachfragen soll. Eine Stunde ist für eine Wehr
|
||||
// reichlich — Absagen kommen zusätzlich per Benachrichtigung.
|
||||
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
|
||||
"X-PUBLISHED-TTL:PT1H",
|
||||
];
|
||||
|
||||
for (const termin of angaben.termine) {
|
||||
zeilen.push("BEGIN:VEVENT");
|
||||
zeilen.push(`UID:${termin.id}@florian`);
|
||||
zeilen.push(`DTSTAMP:${alsZeitstempel(termin.geaendertAm)}`);
|
||||
|
||||
if (termin.ganztaegig) {
|
||||
zeilen.push(`DTSTART;VALUE=DATE:${alsDatum(termin.beginn)}`);
|
||||
// Bei ganztägigen Terminen ist das Ende ausschließend — ein Tag mehr.
|
||||
const ende = termin.ende ?? termin.beginn;
|
||||
const nachher = new Date(ende);
|
||||
nachher.setUTCDate(nachher.getUTCDate() + 1);
|
||||
zeilen.push(`DTEND;VALUE=DATE:${alsDatum(nachher)}`);
|
||||
} else {
|
||||
zeilen.push(`DTSTART:${alsZeitstempel(termin.beginn)}`);
|
||||
if (termin.ende) zeilen.push(`DTEND:${alsZeitstempel(termin.ende)}`);
|
||||
}
|
||||
|
||||
const titel = termin.abgesagt ? `ABGESAGT: ${termin.titel}` : termin.titel;
|
||||
zeilen.push(umbrechen(`SUMMARY:${maskieren(`${titel} (${ART_TEXT[termin.art]})`)}`));
|
||||
|
||||
if (termin.ort) zeilen.push(umbrechen(`LOCATION:${maskieren(termin.ort)}`));
|
||||
|
||||
const beschreibung = [
|
||||
termin.beschreibung,
|
||||
termin.eigenerStand === "ZUGESAGT" ? "Du bist zugesagt." : null,
|
||||
termin.eigenerStand === "ABGESAGT" ? "Du hast abgesagt." : null,
|
||||
termin.eigenerStand === "WARTELISTE" ? "Du stehst auf der Warteliste." : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
if (beschreibung) zeilen.push(umbrechen(`DESCRIPTION:${maskieren(beschreibung)}`));
|
||||
|
||||
zeilen.push(umbrechen(`URL:${angaben.adresse}/termine/${termin.id}`));
|
||||
zeilen.push(`STATUS:${termin.abgesagt ? "CANCELLED" : "CONFIRMED"}`);
|
||||
zeilen.push("END:VEVENT");
|
||||
}
|
||||
|
||||
zeilen.push("END:VCALENDAR");
|
||||
|
||||
// Das Format verlangt CRLF — manche Kalender nehmen es genau.
|
||||
return `${zeilen.join("\r\n")}\r\n`;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Serienmuster } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Serientermine ausschreiben.
|
||||
*
|
||||
* Ein Serientermin wird **nicht** als Regel gespeichert und beim Anzeigen
|
||||
* ausgerechnet, sondern als Liste einzelner Termine angelegt. Der Grund ist
|
||||
* der Alltag einer Wehr: der Übungsabend am 3. Dienstag fällt im August aus,
|
||||
* im Dezember ist er eine Woche früher, und für jeden einzelnen gibt es
|
||||
* Zusagen und eine Anwesenheitsliste.
|
||||
*
|
||||
* Mit einer Regel wäre jede dieser Ausnahmen ein Sonderfall. Mit
|
||||
* ausgeschriebenen Terminen ist es einfach ein Datensatz, den man ändert.
|
||||
*
|
||||
* Der Preis: eine Serie über zwei Jahre sind hundert Zeilen. Das ist
|
||||
* Kleingeld — und beim Anlegen wird die Zahl vorher genannt.
|
||||
*/
|
||||
|
||||
/** Wie viele Termine eine Serie höchstens hat. Zwei Jahre wöchentlich. */
|
||||
export const HOECHSTZAHL = 120;
|
||||
|
||||
export const MUSTER_TEXT: Record<Serienmuster, string> = {
|
||||
KEINE: "einmalig",
|
||||
WOECHENTLICH: "wöchentlich",
|
||||
ZWEIWOECHENTLICH: "alle zwei Wochen",
|
||||
MONATLICH: "monatlich",
|
||||
JAEHRLICH: "jährlich",
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Zeitpunkte einer Serie.
|
||||
*
|
||||
* Gerechnet wird auf dem Kalender, nicht in Millisekunden: „monatlich" heißt
|
||||
* derselbe Tag im nächsten Monat, nicht 30 Tage später. Fällt der Tag im
|
||||
* Zielmonat aus (31. Februar), rutscht der Termin auf den letzten Tag des
|
||||
* Monats — das ist, was ein Mensch erwartet.
|
||||
*/
|
||||
export function serientermine(angaben: {
|
||||
beginn: Date;
|
||||
muster: Serienmuster;
|
||||
/** Bis wann die Serie läuft, einschließlich. */
|
||||
bis: Date;
|
||||
hoechstzahl?: number;
|
||||
}): Date[] {
|
||||
const { beginn, muster, bis } = angaben;
|
||||
const grenze = Math.min(angaben.hoechstzahl ?? HOECHSTZAHL, HOECHSTZAHL);
|
||||
|
||||
if (muster === Serienmuster.KEINE) return [beginn];
|
||||
if (bis.getTime() < beginn.getTime()) return [beginn];
|
||||
|
||||
const termine: Date[] = [];
|
||||
const tagImMonat = beginn.getUTCDate();
|
||||
|
||||
let laufend = new Date(beginn);
|
||||
let nummer = 0;
|
||||
|
||||
while (laufend.getTime() <= bis.getTime() && termine.length < grenze) {
|
||||
termine.push(new Date(laufend));
|
||||
nummer += 1;
|
||||
|
||||
const naechster = new Date(beginn);
|
||||
|
||||
switch (muster) {
|
||||
case Serienmuster.WOECHENTLICH:
|
||||
naechster.setUTCDate(beginn.getUTCDate() + nummer * 7);
|
||||
break;
|
||||
|
||||
case Serienmuster.ZWEIWOECHENTLICH:
|
||||
naechster.setUTCDate(beginn.getUTCDate() + nummer * 14);
|
||||
break;
|
||||
|
||||
case Serienmuster.MONATLICH: {
|
||||
// Erst auf den Ersten, dann den Monat setzen, dann den Tag: sonst
|
||||
// rutscht der 31. Januar bei +1 Monat in den März.
|
||||
naechster.setUTCDate(1);
|
||||
naechster.setUTCMonth(beginn.getUTCMonth() + nummer);
|
||||
const letzterTag = new Date(
|
||||
Date.UTC(naechster.getUTCFullYear(), naechster.getUTCMonth() + 1, 0),
|
||||
).getUTCDate();
|
||||
naechster.setUTCDate(Math.min(tagImMonat, letzterTag));
|
||||
break;
|
||||
}
|
||||
|
||||
case Serienmuster.JAEHRLICH: {
|
||||
naechster.setUTCDate(1);
|
||||
naechster.setUTCFullYear(beginn.getUTCFullYear() + nummer);
|
||||
const letzterTag = new Date(
|
||||
Date.UTC(naechster.getUTCFullYear(), naechster.getUTCMonth() + 1, 0),
|
||||
).getUTCDate();
|
||||
naechster.setUTCDate(Math.min(tagImMonat, letzterTag));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return termine;
|
||||
}
|
||||
|
||||
laufend = naechster;
|
||||
}
|
||||
|
||||
return termine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Termine eine Serie ergäbe.
|
||||
*
|
||||
* Für die Oberfläche: bevor jemand „anlegen" drückt, steht dort, dass daraus
|
||||
* 52 Einträge werden.
|
||||
*/
|
||||
export function serienlaenge(angaben: {
|
||||
beginn: Date;
|
||||
muster: Serienmuster;
|
||||
bis: Date;
|
||||
}): number {
|
||||
return serientermine(angaben).length;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Teilnahmestand, TerminArt, Zustellstand } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Klartext für Termine und Zustellung.
|
||||
*
|
||||
* Eigene Datei, weil die Oberfläche sie im Browser braucht — die Module
|
||||
* daneben sind teils `server-only`, und ein Import daraus würde den Build
|
||||
* zerreißen. Dieselbe Trennung wie bei wetter-texte.ts aus Phase 4.
|
||||
*/
|
||||
|
||||
export const TERMINART_TEXT: Record<TerminArt, string> = {
|
||||
DIENST: "Dienst",
|
||||
UEBUNG: "Übung",
|
||||
LEHRGANG: "Lehrgang",
|
||||
ARBEITSDIENST: "Arbeitsdienst",
|
||||
VERANSTALTUNG: "Veranstaltung",
|
||||
SITZUNG: "Sitzung",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
export const TEILNAHME_TEXT: Record<Teilnahmestand, string> = {
|
||||
ZUGESAGT: "Zugesagt",
|
||||
ABGESAGT: "Abgesagt",
|
||||
WARTELISTE: "Warteliste",
|
||||
};
|
||||
|
||||
export const ZUSTELLSTAND_TEXT: Record<Zustellstand, string> = {
|
||||
WARTET: "Wartet",
|
||||
GESENDET: "Zugestellt",
|
||||
FEHLGESCHLAGEN: "Fehlgeschlagen",
|
||||
UEBERSPRUNGEN: "Übersprungen",
|
||||
};
|
||||
|
||||
/**
|
||||
* Wie lange es noch hin ist, in Worten.
|
||||
*
|
||||
* „In 3 Tagen" liest sich schneller als ein Datum, das man erst mit dem
|
||||
* heutigen vergleichen muss. Das Datum steht trotzdem daneben — bei allem,
|
||||
* was weiter weg ist als eine Woche, will man es genau wissen.
|
||||
*/
|
||||
export function nochHin(beginn: Date, jetzt = new Date()): string {
|
||||
const stunden = Math.round((beginn.getTime() - jetzt.getTime()) / 3_600_000);
|
||||
|
||||
if (stunden < 0) return "vorbei";
|
||||
if (stunden === 0) return "gleich";
|
||||
if (stunden < 24) return stunden === 1 ? "in einer Stunde" : `in ${stunden} Stunden`;
|
||||
|
||||
const tage = Math.round(stunden / 24);
|
||||
if (tage === 1) return "morgen";
|
||||
if (tage < 14) return `in ${tage} Tagen`;
|
||||
|
||||
const wochen = Math.round(tage / 7);
|
||||
if (wochen < 9) return `in ${wochen} Wochen`;
|
||||
|
||||
return `in ${Math.round(tage / 30)} Monaten`;
|
||||
}
|
||||
@@ -30,6 +30,10 @@ export const AUFTRAG = {
|
||||
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
||||
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
|
||||
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
|
||||
/** Stellt zu, was in der Benachrichtigungstabelle wartet (Phase 7). */
|
||||
BENACHRICHTIGUNG_ZUSTELLEN: "benachrichtigung.zustellen",
|
||||
/** Legt Termin- und Qualifikationserinnerungen an (Phase 7). */
|
||||
BENACHRICHTIGUNG_ERINNERN: "benachrichtigung.erinnern",
|
||||
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
|
||||
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
||||
} as const;
|
||||
|
||||
@@ -24,6 +24,9 @@ import { atemschutzTraegerRouter } from "@/server/routers/atemschutz/traeger";
|
||||
import { hydrantRouter } from "@/server/routers/karte/hydrant";
|
||||
import { objektRouter } from "@/server/routers/karte/objekt";
|
||||
import { lagekarteRouter, wasserfoerderungRouter } from "@/server/routers/karte/lagekarte";
|
||||
import { terminRouter } from "@/server/routers/termin/termin";
|
||||
import { benachrichtigungRouter } from "@/server/routers/termin/benachrichtigung";
|
||||
import { dienstausweisRouter } from "@/server/routers/stammdaten/dienstausweis";
|
||||
|
||||
/**
|
||||
* Wurzel-Router.
|
||||
@@ -181,6 +184,11 @@ export const appRouter = router({
|
||||
objekt: objektRouter,
|
||||
lagekarte: lagekarteRouter,
|
||||
wasserfoerderung: wasserfoerderungRouter,
|
||||
|
||||
// Phase 7 — Benachrichtigungen und Termine
|
||||
termin: terminRouter,
|
||||
benachrichtigung: benachrichtigungRouter,
|
||||
dienstausweis: dienstausweisRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EinsatzFunktion,
|
||||
EinsatzKategorie,
|
||||
EinsatzStatus,
|
||||
EreignisTyp,
|
||||
Prisma,
|
||||
} from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
@@ -23,6 +24,8 @@ import { kennzahlen, zeitHinweise, ZEITSTEMPEL } from "@/lib/einsatz/zeiten";
|
||||
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
|
||||
import { downloadLink } from "@/lib/storage";
|
||||
import { portalAdresse, tokenZusammensetzen, zugangErzeugen } from "@/lib/einsatz/portal";
|
||||
import { benachrichtigungenAnlegenIn } from "@/lib/benachrichtigung/versand";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Einsätze.
|
||||
@@ -1030,6 +1033,20 @@ export const einsatzRouter = router({
|
||||
console.error("[einsatz] Berichtsauftrag ließ sich nicht einstellen:", fehler);
|
||||
});
|
||||
|
||||
// Wer den Bericht abonniert hat, erfährt von der Freigabe (Phase 7).
|
||||
// Sie ist der Punkt, ab dem der Bericht festgeschrieben ist — wer
|
||||
// etwas anzumerken hat, muss es jetzt sagen.
|
||||
await benachrichtigungenAnlegenIn(db, ctx.sitzung.organisationId, {
|
||||
ereignis: EreignisTyp.EINSATZBERICHT_FREIGEGEBEN,
|
||||
werte: {
|
||||
einsatznummer: einsatz.einsatznummer,
|
||||
stichwort: einsatz.stichwort,
|
||||
datum: alsDatum(einsatz.alarmiertUm),
|
||||
},
|
||||
entitaet: "Einsatz",
|
||||
entitaetId: input.id,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { router, angemeldet } from "@/server/trpc";
|
||||
import { AUSWEIS_GUELTIG_SEKUNDEN, ausweisErzeugen } from "@/lib/dienstausweis";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Der digitale Dienstausweis.
|
||||
*
|
||||
* Nur eine Abfrage, und sie gibt immer den Ausweis der eigenen Person aus —
|
||||
* es gibt bewusst keinen Weg, sich den Ausweis eines anderen ausstellen zu
|
||||
* lassen. Ein Ausweis, den jemand für einen Dritten erzeugen kann, ist keiner.
|
||||
*
|
||||
* Kein eigenes Recht: einen Ausweis über sich selbst darf jeder haben, der in
|
||||
* der Wehr ist. Was darauf steht, steht ohnehin auf jeder Mitgliederliste.
|
||||
*/
|
||||
export const dienstausweisRouter = router({
|
||||
eigener: angemeldet.query(async ({ ctx }) => {
|
||||
const person = await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
select: {
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
dienstgrad: true,
|
||||
organisation: { select: { name: true, kurzname: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const code = ausweisErzeugen(ctx.sitzung.personId, ctx.sitzung.organisationId);
|
||||
|
||||
return {
|
||||
person,
|
||||
code,
|
||||
/** Was in den QR-Code geschrieben wird. */
|
||||
adresse: `${env.APP_URL}/ausweis/${encodeURIComponent(code)}`,
|
||||
gueltigSekunden: AUSWEIS_GUELTIG_SEKUNDEN,
|
||||
gueltigBis: new Date(Date.now() + AUSWEIS_GUELTIG_SEKUNDEN * 1000),
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,634 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { EreignisTyp, Kanal, Zustellstand } from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import {
|
||||
EREIGNIS_TEXT,
|
||||
KANAL_EXTERN,
|
||||
KANAL_TEXT,
|
||||
PLATZHALTER,
|
||||
STANDARD,
|
||||
nachrichtBauen,
|
||||
unbekanntePlatzhalter,
|
||||
} from "@/lib/benachrichtigung/vorlagen";
|
||||
import { webPush } from "@/lib/integrations/webpush";
|
||||
import { themaVorschlagen } from "@/lib/integrations/ntfy";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Benachrichtigungen: wer worüber wie erfährt.
|
||||
*
|
||||
* Zwei getrennte Bereiche, und die Trennung ist Absicht:
|
||||
*
|
||||
* * **Die eigenen Wege** richtet jeder für sich ein. Ob jemand eine Mail
|
||||
* will, einen Push aufs Handy oder gar nichts, geht niemanden sonst etwas
|
||||
* an — auch den Kommandanten nicht. Deshalb hängt das am Grundrecht
|
||||
* `benachrichtigung.eigene`, das jedes Mitglied hat.
|
||||
* * **Die Textvorlagen** gehören der Wehr. Wer sie ändert, ändert, was alle
|
||||
* bekommen — dafür braucht es `benachrichtigung.vorlagen`.
|
||||
*
|
||||
* Und der Satz, der über allem steht: **niemand wird ungefragt
|
||||
* benachrichtigt.** Ein neu angelegter Zustellkanal hat eine leere
|
||||
* Ereignisliste. Erst wer ankreuzt, wofür er ihn will, bekommt etwas.
|
||||
*/
|
||||
|
||||
const kanalFelder = z.object({
|
||||
kanal: z.nativeEnum(Kanal),
|
||||
ziel: z.string().trim().max(500),
|
||||
p256dh: z.string().trim().max(200).nullish(),
|
||||
auth: z.string().trim().max(200).nullish(),
|
||||
bezeichnung: z.string().trim().max(120).nullish(),
|
||||
ereignisse: z.array(z.nativeEnum(EreignisTyp)).max(20).default([]),
|
||||
});
|
||||
|
||||
export const benachrichtigungRouter = router({
|
||||
/**
|
||||
* Was es zum Einstellen gibt.
|
||||
*
|
||||
* Die Oberfläche baut daraus die Kreuzchenliste. Der Schlüssel ist
|
||||
* `extern`: daneben steht in der Ansicht der Hinweis, dass dorthin nur
|
||||
* Kurzfassungen gehen — bevor jemand Namen in eine Vorlage schreibt, die
|
||||
* bei Telegram landet.
|
||||
*/
|
||||
moeglichkeiten: angemeldet.query(() => ({
|
||||
ereignisse: Object.values(EreignisTyp).map((ereignis) => ({
|
||||
wert: ereignis,
|
||||
text: EREIGNIS_TEXT[ereignis],
|
||||
platzhalter: PLATZHALTER[ereignis],
|
||||
})),
|
||||
kanaele: Object.values(Kanal).map((kanal) => ({
|
||||
wert: kanal,
|
||||
text: KANAL_TEXT[kanal],
|
||||
extern: KANAL_EXTERN[kanal],
|
||||
// Ein Kanal, der in der Umgebung nicht eingerichtet ist, wird angezeigt
|
||||
// aber ausgegraut — sonst sucht jemand vergeblich nach Telegram und
|
||||
// weiß nicht, dass es am Server liegt.
|
||||
eingerichtet: eingerichtet(kanal),
|
||||
})),
|
||||
/** Der Browser braucht ihn, um sich für Push anzumelden. */
|
||||
webPushSchluessel: webPush().oeffentlicherSchluessel(),
|
||||
/** Vorschlag für ein ntfy-Thema, das niemand erraten kann. */
|
||||
ntfyVorschlag: env.NTFY_ENABLED ? themaVorschlagen() : null,
|
||||
ntfyServer: env.NTFY_URL ?? null,
|
||||
})),
|
||||
|
||||
/** Die eigenen Zustellwege. */
|
||||
eigeneKanaele: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) =>
|
||||
ctx.db((db) =>
|
||||
db.zustellkanal.findMany({
|
||||
where: { personId: ctx.sitzung.personId },
|
||||
orderBy: [{ kanal: "asc" }, { erstelltAm: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
kanal: true,
|
||||
ziel: true,
|
||||
bezeichnung: true,
|
||||
ereignisse: true,
|
||||
aktiv: true,
|
||||
letzteZustellung: true,
|
||||
fehlversuche: true,
|
||||
erstelltAm: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/**
|
||||
* Einen Zustellweg einrichten.
|
||||
*
|
||||
* Immer für die eigene Person — es gibt bewusst keinen Weg, jemand anderem
|
||||
* einen Kanal einzutragen. Wer den Kommandanten für alle anmelden könnte,
|
||||
* hätte binnen einer Woche eine Wehr, die die Benachrichtigungen abschaltet.
|
||||
*/
|
||||
kanalAnlegen: mitRecht("benachrichtigung.eigene")
|
||||
.input(kanalFelder)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.kanal === Kanal.WEBPUSH && (!input.p256dh || !input.auth)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Einem Push-Abo fehlen die Schlüssel — bitte die Meldung des Browsers erlauben.",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.kanal === Kanal.MAIL && input.ziel) {
|
||||
const person = await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
select: { email: true },
|
||||
}),
|
||||
);
|
||||
// Die eigene Adresse ist zulässig, eine fremde nicht: sonst wäre das
|
||||
// hier ein bequemer Weg, jemanden ungefragt zuzuschütten.
|
||||
if (person?.email && person.email.toLowerCase() !== input.ziel.toLowerCase()) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Für E-Mail wird die hinterlegte Adresse benutzt. Eine andere bitte in den " +
|
||||
"eigenen Daten ändern.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.db(async (db) => {
|
||||
const kanal = await db.zustellkanal.upsert({
|
||||
where: {
|
||||
personId_kanal_ziel: {
|
||||
personId: ctx.sitzung.personId,
|
||||
kanal: input.kanal,
|
||||
ziel: input.ziel,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: ctx.sitzung.personId,
|
||||
kanal: input.kanal,
|
||||
ziel: input.ziel,
|
||||
p256dh: input.p256dh ?? null,
|
||||
auth: input.auth ?? null,
|
||||
bezeichnung: input.bezeichnung ?? null,
|
||||
ereignisse: input.ereignisse,
|
||||
},
|
||||
// Ein erneutes Anmelden desselben Browsers weckt einen stillgelegten
|
||||
// Kanal wieder auf, statt einen zweiten daneben zu legen.
|
||||
update: {
|
||||
p256dh: input.p256dh ?? null,
|
||||
auth: input.auth ?? null,
|
||||
bezeichnung: input.bezeichnung ?? null,
|
||||
ereignisse: input.ereignisse,
|
||||
aktiv: true,
|
||||
fehlversuche: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Zustellkanal",
|
||||
entitaetId: kanal.id,
|
||||
beschreibung: `Zustellweg ${KANAL_TEXT[input.kanal]} eingerichtet`,
|
||||
// Das Ziel ist bei Push eine sehr lange Adresse und bei Telegram
|
||||
// eine Kennung — beides gehört nicht ins Protokoll.
|
||||
nachher: { kanal: input.kanal, ereignisse: input.ereignisse },
|
||||
});
|
||||
|
||||
return { id: kanal.id };
|
||||
});
|
||||
}),
|
||||
|
||||
/** Wofür ein Kanal benutzt wird — oder ob er ruht. */
|
||||
kanalAendern: mitRecht("benachrichtigung.eigene")
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
ereignisse: z.array(z.nativeEnum(EreignisTyp)).max(20).optional(),
|
||||
aktiv: z.boolean().optional(),
|
||||
bezeichnung: z.string().trim().max(120).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.zustellkanal.findUnique({ where: { id: input.id } });
|
||||
|
||||
if (!vorher || vorher.personId !== ctx.sitzung.personId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Diesen Zustellweg gibt es nicht.",
|
||||
});
|
||||
}
|
||||
|
||||
const nachher = await db.zustellkanal.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.ereignisse ? { ereignisse: input.ereignisse } : {}),
|
||||
...(input.aktiv === undefined ? {} : { aktiv: input.aktiv, fehlversuche: 0 }),
|
||||
...(input.bezeichnung === undefined ? {} : { bezeichnung: input.bezeichnung }),
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Zustellkanal",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Zustellweg ${KANAL_TEXT[vorher.kanal]} geändert`,
|
||||
vorher: { ereignisse: vorher.ereignisse, aktiv: vorher.aktiv },
|
||||
nachher: { ereignisse: nachher.ereignisse, aktiv: nachher.aktiv },
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Einen Zustellweg löschen.
|
||||
*
|
||||
* Hier wird wirklich gelöscht, nicht weichgelöscht: ein Push-Endpunkt und
|
||||
* eine Telegram-Kennung sind personenbezogene Daten ohne Aufbewahrungsgrund.
|
||||
* Wer seinen Kanal entfernt, will ihn weg haben. Was darüber verschickt
|
||||
* wurde, bleibt in der Benachrichtigungsliste — aber ohne den Weg dorthin.
|
||||
*/
|
||||
kanalLoeschen: mitRecht("benachrichtigung.eigene")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const kanal = await db.zustellkanal.findUnique({ where: { id: input.id } });
|
||||
|
||||
if (!kanal || kanal.personId !== ctx.sitzung.personId) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Zustellweg gibt es nicht." });
|
||||
}
|
||||
|
||||
await db.zustellkanal.delete({ where: { id: input.id } });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Zustellkanal",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Zustellweg ${KANAL_TEXT[kanal.kanal]} entfernt`,
|
||||
vorher: { kanal: kanal.kanal, ereignisse: kanal.ereignisse },
|
||||
});
|
||||
|
||||
return { erfolgreich: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Eine Probenachricht schicken.
|
||||
*
|
||||
* Der wichtigste Knopf auf der ganzen Seite. Ohne ihn merkt jemand erst in
|
||||
* drei Wochen, dass sein ntfy-Thema einen Tippfehler hat — nämlich dann,
|
||||
* wenn die Nachricht wichtig gewesen wäre.
|
||||
*/
|
||||
probe: mitRecht("benachrichtigung.eigene")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const kanal = await ctx.db((db) =>
|
||||
db.zustellkanal.findUnique({ where: { id: input.id } }),
|
||||
);
|
||||
|
||||
if (!kanal || kanal.personId !== ctx.sitzung.personId) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Zustellweg gibt es nicht." });
|
||||
}
|
||||
|
||||
// Die Probe geht durch dieselbe Maschinerie wie alles andere: sie wird
|
||||
// als Benachrichtigung angelegt und vom Worker zugestellt. Ein
|
||||
// Sonderweg, der funktioniert, während der echte Weg klemmt, wäre
|
||||
// schlimmer als kein Probeknopf.
|
||||
await ctx.db((db) =>
|
||||
db.benachrichtigung.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: ctx.sitzung.personId,
|
||||
ereignis: EreignisTyp.TERMIN_ERINNERUNG,
|
||||
kanal: kanal.kanal,
|
||||
ziel: kanal.ziel,
|
||||
betreff: "Probenachricht aus FLORIAN",
|
||||
text:
|
||||
`Wenn du das liest, funktioniert dein Zustellweg über ${KANAL_TEXT[kanal.kanal]}.\n\n` +
|
||||
`Ausgelöst von ${ctx.sitzung.name}.`,
|
||||
entitaet: "Zustellkanal",
|
||||
entitaetId: kanal.id,
|
||||
stand: Zustellstand.WARTET,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
erfolgreich: true,
|
||||
hinweis:
|
||||
"Die Probenachricht ist eingereiht. Sie geht mit dem nächsten Lauf des " +
|
||||
"Hintergrundprozesses raus — üblicherweise binnen einer Minute.",
|
||||
};
|
||||
}),
|
||||
|
||||
/** Die eigenen Benachrichtigungen, jüngste zuerst. */
|
||||
eigeneListe: mitRecht("benachrichtigung.eigene")
|
||||
.input(z.object({ anzahl: z.number().int().min(1).max(200).default(50) }).optional())
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.benachrichtigung.findMany({
|
||||
where: { personId: ctx.sitzung.personId },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
take: input?.anzahl ?? 50,
|
||||
select: {
|
||||
id: true,
|
||||
ereignis: true,
|
||||
kanal: true,
|
||||
betreff: true,
|
||||
text: true,
|
||||
stand: true,
|
||||
fehler: true,
|
||||
gesendetAm: true,
|
||||
erstelltAm: true,
|
||||
entitaet: true,
|
||||
entitaetId: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vorlagen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Die Vorlagen der Wehr, ergänzt um die eingebauten Standardtexte.
|
||||
*
|
||||
* Die Oberfläche zeigt beides in einer Liste: was eingestellt ist, und was
|
||||
* gälte, wenn nichts eingestellt wäre. So sieht man auf einen Blick, was
|
||||
* abweicht.
|
||||
*/
|
||||
vorlagen: mitRecht("benachrichtigung.vorlagen").query(async ({ ctx }) => {
|
||||
const eigene = await ctx.db((db) =>
|
||||
db.benachrichtigungsvorlage.findMany({ orderBy: [{ ereignis: "asc" }, { kanal: "asc" }] }),
|
||||
);
|
||||
|
||||
return {
|
||||
eigene,
|
||||
standard: Object.values(EreignisTyp).map((ereignis) => ({
|
||||
ereignis,
|
||||
text: EREIGNIS_TEXT[ereignis],
|
||||
platzhalter: PLATZHALTER[ereignis],
|
||||
lang: STANDARD[ereignis],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
/** Eine Vorlage speichern — oder auf den Standard zurücksetzen. */
|
||||
vorlageSpeichern: mitRecht("benachrichtigung.vorlagen")
|
||||
.input(
|
||||
z.object({
|
||||
ereignis: z.nativeEnum(EreignisTyp),
|
||||
kanal: z.nativeEnum(Kanal),
|
||||
betreff: z.string().trim().max(200).nullish(),
|
||||
text: z.string().trim().min(1).max(2000),
|
||||
aktiv: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Ein Platzhalter, den es für dieses Ereignis nicht gibt, würde in jeder
|
||||
// Nachricht als `{foo}` stehen bleiben. Das fällt hier auf, nicht erst
|
||||
// beim Empfänger.
|
||||
const unbekannt = [
|
||||
...unbekanntePlatzhalter(input.ereignis, input.text),
|
||||
...unbekanntePlatzhalter(input.ereignis, input.betreff ?? ""),
|
||||
];
|
||||
|
||||
if (unbekannt.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
`Unbekannte Platzhalter: ${unbekannt.map((name) => `{${name}}`).join(", ")}. ` +
|
||||
`Erlaubt sind: ${PLATZHALTER[input.ereignis].map((name) => `{${name}}`).join(", ")}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.db(async (db) => {
|
||||
const vorlage = await db.benachrichtigungsvorlage.upsert({
|
||||
where: {
|
||||
organisationId_ereignis_kanal: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
ereignis: input.ereignis,
|
||||
kanal: input.kanal,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
ereignis: input.ereignis,
|
||||
kanal: input.kanal,
|
||||
betreff: input.betreff ?? null,
|
||||
text: input.text,
|
||||
aktiv: input.aktiv,
|
||||
},
|
||||
update: {
|
||||
betreff: input.betreff ?? null,
|
||||
text: input.text,
|
||||
aktiv: input.aktiv,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Benachrichtigungsvorlage",
|
||||
entitaetId: vorlage.id,
|
||||
beschreibung: `Vorlage "${EREIGNIS_TEXT[input.ereignis]}" für ${KANAL_TEXT[input.kanal]} gespeichert`,
|
||||
nachher: { betreff: input.betreff, text: input.text, aktiv: input.aktiv },
|
||||
});
|
||||
|
||||
return vorlage;
|
||||
});
|
||||
}),
|
||||
|
||||
/** Zurück auf den eingebauten Text. */
|
||||
vorlageZuruecksetzen: mitRecht("benachrichtigung.vorlagen")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorlage = await db.benachrichtigungsvorlage.findUnique({ where: { id: input.id } });
|
||||
if (!vorlage) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
||||
}
|
||||
|
||||
await db.benachrichtigungsvorlage.delete({ where: { id: input.id } });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Benachrichtigungsvorlage",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Vorlage "${EREIGNIS_TEXT[vorlage.ereignis]}" auf den Standard zurückgesetzt`,
|
||||
vorher: { betreff: vorlage.betreff, text: vorlage.text },
|
||||
});
|
||||
|
||||
return { erfolgreich: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Wie eine Vorlage mit Beispielwerten aussähe. */
|
||||
vorschau: mitRecht("benachrichtigung.vorlagen")
|
||||
.input(
|
||||
z.object({
|
||||
ereignis: z.nativeEnum(EreignisTyp),
|
||||
kanal: z.nativeEnum(Kanal),
|
||||
betreff: z.string().max(200).nullish(),
|
||||
text: z.string().max(2000),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) => {
|
||||
// Beispielwerte für alle Platzhalter des Ereignisses. Sie sind erkennbar
|
||||
// erfunden — niemand soll die Vorschau für einen echten Vorgang halten.
|
||||
const werte: Record<string, string | number> = {};
|
||||
for (const name of PLATZHALTER[input.ereignis]) {
|
||||
werte[name] = BEISPIEL[name] ?? `‹${name}›`;
|
||||
}
|
||||
|
||||
return nachrichtBauen({
|
||||
ereignis: input.ereignis,
|
||||
kanal: input.kanal,
|
||||
werte,
|
||||
vorlage: input.text ? { betreff: input.betreff ?? null, text: input.text } : null,
|
||||
});
|
||||
}),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kalenderzugang
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Die eigenen Kalender-Abonnements. */
|
||||
kalenderzugaenge: mitRecht("benachrichtigung.eigene").query(async ({ ctx }) =>
|
||||
ctx.db((db) =>
|
||||
db.kalenderzugang.findMany({
|
||||
where: { personId: ctx.sitzung.personId },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
bezeichnung: true,
|
||||
nurEigene: true,
|
||||
aktiv: true,
|
||||
zugriffe: true,
|
||||
letzterZugriff: true,
|
||||
erstelltAm: true,
|
||||
widerrufenAm: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/**
|
||||
* Eine Kalenderadresse erzeugen.
|
||||
*
|
||||
* Das Token steht **genau einmal** in der Antwort; gespeichert wird nur sein
|
||||
* SHA-256. Wer die Adresse verliert, erzeugt eine neue und widerruft die
|
||||
* alte — das ist sicherer, als sie nachträglich anzeigen zu können.
|
||||
*
|
||||
* Kein Salz, kein bcrypt: das Token ist 32 zufällige Bytes, da gibt es
|
||||
* nichts zu erraten und kein Wörterbuch, gegen das man es prüfen könnte.
|
||||
* Ein einfacher Hash reicht und ist bei jedem Abruf schnell nachschlagbar.
|
||||
*/
|
||||
kalenderzugangAnlegen: mitRecht("benachrichtigung.eigene")
|
||||
.input(
|
||||
z.object({
|
||||
bezeichnung: z.string().trim().min(1).max(120),
|
||||
nurEigene: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const zugang = await ctx.db(async (db) => {
|
||||
const eintrag = await db.kalenderzugang.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
personId: ctx.sitzung.personId,
|
||||
bezeichnung: input.bezeichnung,
|
||||
nurEigene: input.nurEigene,
|
||||
tokenHash,
|
||||
},
|
||||
select: { id: true, bezeichnung: true },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Kalenderzugang",
|
||||
entitaetId: eintrag.id,
|
||||
beschreibung: `Kalenderadresse "${input.bezeichnung}" erzeugt`,
|
||||
nachher: { bezeichnung: input.bezeichnung, nurEigene: input.nurEigene },
|
||||
});
|
||||
|
||||
return eintrag;
|
||||
});
|
||||
|
||||
return {
|
||||
...zugang,
|
||||
/** Nur jetzt sichtbar. */
|
||||
adresse: `${env.APP_URL}/api/kalender/${token}.ics`,
|
||||
hinweis:
|
||||
"Diese Adresse ist ein Schlüssel: wer sie hat, sieht die Termine. Sie wird hier " +
|
||||
"nur ein einziges Mal angezeigt.",
|
||||
};
|
||||
}),
|
||||
|
||||
/** Eine Kalenderadresse widerrufen. */
|
||||
kalenderzugangWiderrufen: mitRecht("benachrichtigung.eigene")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const zugang = await db.kalenderzugang.findUnique({ where: { id: input.id } });
|
||||
|
||||
if (!zugang || zugang.personId !== ctx.sitzung.personId) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Zugang gibt es nicht." });
|
||||
}
|
||||
|
||||
await db.kalenderzugang.update({
|
||||
where: { id: input.id },
|
||||
data: { aktiv: false, widerrufenAm: new Date() },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Kalenderzugang",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Kalenderadresse "${zugang.bezeichnung}" widerrufen`,
|
||||
vorher: { aktiv: true },
|
||||
nachher: { aktiv: false },
|
||||
});
|
||||
|
||||
return { erfolgreich: true };
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Ob der Kanal in dieser Installation überhaupt eingerichtet ist. */
|
||||
function eingerichtet(kanal: Kanal): boolean {
|
||||
switch (kanal) {
|
||||
case Kanal.MAIL:
|
||||
return env.MAIL_ENABLED;
|
||||
case Kanal.TELEGRAM:
|
||||
return env.TELEGRAM_ENABLED;
|
||||
case Kanal.NTFY:
|
||||
return env.NTFY_ENABLED;
|
||||
case Kanal.WEBPUSH:
|
||||
return env.WEBPUSH_ENABLED;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Erkennbar erfundene Werte für die Vorschau. */
|
||||
const BEISPIEL: Record<string, string | number> = {
|
||||
wehr: "FF Musterdorf",
|
||||
anzahl: 3,
|
||||
geraet: "Pressluftatmer PA 90",
|
||||
inventarnummer: "INV-0042",
|
||||
faelligAm: "31.10.2026",
|
||||
tage: 12,
|
||||
beschreibung: "Lungenautomat undicht, Prüfstand zeigt Druckabfall.",
|
||||
prioritaet: "HOCH",
|
||||
melder: "Beispiel, Max",
|
||||
person: "Beispiel, Max",
|
||||
qualifikation: "Atemschutzgeräteträger G 26.3",
|
||||
gueltigBis: "01.03.2027",
|
||||
titel: "Übungsabend",
|
||||
art: "UEBUNG",
|
||||
datum: "13.10.2026",
|
||||
uhrzeit: "19:30",
|
||||
ort: "Gerätehaus",
|
||||
leiter: "Beispiel, Max",
|
||||
grund: "Sturmwarnung",
|
||||
einsatznummer: "E-2026-0042",
|
||||
stichwort: "B 3 Wohnhausbrand",
|
||||
};
|
||||
@@ -0,0 +1,736 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { EreignisTyp, Serienmuster, Teilnahmestand, TerminArt } from "@prisma/client";
|
||||
import { router, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { HOECHSTZAHL, serientermine } from "@/lib/termin/serie";
|
||||
import { benachrichtigungenAnlegenIn } from "@/lib/benachrichtigung/versand";
|
||||
import { alsDatum, alsUhrzeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Termine der Wehr.
|
||||
*
|
||||
* Der Kalender ist die Stelle, an der eine Wehr am ehesten merkt, ob eine
|
||||
* Software zu ihr passt. Drei Entscheidungen prägen ihn:
|
||||
*
|
||||
* * **Anmeldung ist die Ausnahme, nicht die Regel.** Beim normalen
|
||||
* Übungsabend meldet sich niemand an, man kommt oder man kommt nicht.
|
||||
* Erst beim Lehrgang mit zwölf Plätzen und beim Ausflug mit Busbestellung
|
||||
* wird `anmeldungNoetig` gesetzt.
|
||||
* * **Abgesagt, nicht gelöscht.** Wer zugesagt hat, muss den Grund sehen.
|
||||
* Ein verschwundener Termin ist schlimmer als ein durchgestrichener.
|
||||
* * **Die Warteliste rückt von selbst nach.** Sagt jemand ab, wird der
|
||||
* Erste von der Warteliste zugesagt — und erfährt es. Sonst führt jemand
|
||||
* die Liste von Hand, und das tut niemand zuverlässig.
|
||||
*/
|
||||
|
||||
const terminFelder = z.object({
|
||||
art: z.nativeEnum(TerminArt).default(TerminArt.DIENST),
|
||||
titel: z.string().trim().min(1).max(200),
|
||||
beschreibung: z.string().trim().max(4000).nullish(),
|
||||
ort: z.string().trim().max(200).nullish(),
|
||||
beginn: z.date(),
|
||||
ende: z.date().nullish(),
|
||||
ganztaegig: z.boolean().default(false),
|
||||
leiterId: z.string().uuid().nullish(),
|
||||
teilnehmerLimit: z.number().int().min(1).max(1000).nullish(),
|
||||
anmeldeschluss: z.date().nullish(),
|
||||
anmeldungNoetig: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const listenauswahl = {
|
||||
id: true,
|
||||
art: true,
|
||||
titel: true,
|
||||
ort: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
ganztaegig: true,
|
||||
anmeldungNoetig: true,
|
||||
teilnehmerLimit: true,
|
||||
anmeldeschluss: true,
|
||||
serieId: true,
|
||||
serienmuster: true,
|
||||
abgesagtAm: true,
|
||||
absageGrund: true,
|
||||
leiter: { select: { id: true, vorname: true, nachname: true } },
|
||||
} as const;
|
||||
|
||||
/** Prüft, was an einem Termin logisch nicht zusammenpasst. */
|
||||
function widersprueche(daten: z.infer<typeof terminFelder>): string | null {
|
||||
if (daten.ende && daten.ende.getTime() < daten.beginn.getTime()) {
|
||||
return "Der Termin endet vor seinem Beginn.";
|
||||
}
|
||||
if (daten.anmeldeschluss && daten.anmeldeschluss.getTime() > daten.beginn.getTime()) {
|
||||
return "Der Anmeldeschluss liegt nach dem Termin.";
|
||||
}
|
||||
if (daten.teilnehmerLimit && !daten.anmeldungNoetig) {
|
||||
return "Eine Begrenzung ohne Anmeldepflicht ergibt keine Warteliste — bitte Anmeldung verlangen.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Werte für die Textbausteine einer Terminbenachrichtigung.
|
||||
*
|
||||
* An einer Stelle, weil dieselben Angaben in drei Ereignissen vorkommen und
|
||||
* ein „{ort}", das nur bei der Erinnerung gefüllt wird, ein ärgerlicher
|
||||
* Fehler wäre.
|
||||
*/
|
||||
function terminwerte(termin: {
|
||||
titel: string;
|
||||
art: TerminArt;
|
||||
ort: string | null;
|
||||
beginn: Date;
|
||||
leiter?: { vorname: string; nachname: string } | null;
|
||||
}): Record<string, string | number | null> {
|
||||
return {
|
||||
titel: termin.titel,
|
||||
art: termin.art,
|
||||
datum: alsDatum(termin.beginn),
|
||||
uhrzeit: alsUhrzeit(termin.beginn),
|
||||
ort: termin.ort,
|
||||
leiter: termin.leiter ? `${termin.leiter.vorname} ${termin.leiter.nachname}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const terminRouter = router({
|
||||
/**
|
||||
* Der Kalender.
|
||||
*
|
||||
* Standardmäßig ab heute nach vorn — das ist, was neunundneunzig Prozent der
|
||||
* Aufrufe wollen. Wer zurückschaut, sagt es ausdrücklich.
|
||||
*/
|
||||
liste: mitRecht("termin.lesen")
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
von: z.date().optional(),
|
||||
bis: z.date().optional(),
|
||||
art: z.nativeEnum(TerminArt).optional(),
|
||||
/** Nur Termine, zu denen ich zu- oder abgesagt habe. */
|
||||
nurEigene: z.boolean().default(false),
|
||||
mitAbgesagten: z.boolean().default(true),
|
||||
anzahl: z.number().int().min(1).max(500).default(100),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const von = input?.von ?? new Date();
|
||||
const rueckwaerts = Boolean(input?.bis && input.bis.getTime() < Date.now());
|
||||
|
||||
return ctx.db(async (db) => {
|
||||
const termine = await db.termin.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
beginn: { gte: von, ...(input?.bis ? { lte: input.bis } : {}) },
|
||||
...(input?.art ? { art: input.art } : {}),
|
||||
...(input?.mitAbgesagten === false ? { abgesagtAm: null } : {}),
|
||||
...(input?.nurEigene
|
||||
? { anmeldungen: { some: { personId: ctx.sitzung.personId } } }
|
||||
: {}),
|
||||
},
|
||||
orderBy: { beginn: rueckwaerts ? "desc" : "asc" },
|
||||
take: input?.anzahl ?? 100,
|
||||
select: {
|
||||
...listenauswahl,
|
||||
_count: { select: { anmeldungen: { where: { stand: Teilnahmestand.ZUGESAGT } } } },
|
||||
anmeldungen: {
|
||||
where: { personId: ctx.sitzung.personId },
|
||||
select: { stand: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return termine.map(({ anmeldungen, _count, ...termin }) => ({
|
||||
...termin,
|
||||
zusagen: _count.anmeldungen,
|
||||
eigenerStand: anmeldungen[0]?.stand ?? null,
|
||||
}));
|
||||
});
|
||||
}),
|
||||
|
||||
einzeln: mitRecht("termin.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const termin = await ctx.db((db) =>
|
||||
db.termin.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
leiter: { select: { id: true, vorname: true, nachname: true, dienstgrad: true } },
|
||||
anmeldungen: {
|
||||
orderBy: [{ stand: "asc" }, { gemeldetAm: "asc" }],
|
||||
include: {
|
||||
person: {
|
||||
select: { id: true, vorname: true, nachname: true, dienstgrad: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!termin || termin.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
|
||||
const zugesagt = termin.anmeldungen.filter(
|
||||
(eintrag) => eintrag.stand === Teilnahmestand.ZUGESAGT,
|
||||
);
|
||||
|
||||
return {
|
||||
...termin,
|
||||
zusagen: zugesagt.length,
|
||||
// Frei heißt: es gibt eine Grenze und sie ist noch nicht erreicht.
|
||||
freiePlaetze: termin.teilnehmerLimit
|
||||
? Math.max(0, termin.teilnehmerLimit - zugesagt.length)
|
||||
: null,
|
||||
eigenerStand:
|
||||
termin.anmeldungen.find((eintrag) => eintrag.personId === ctx.sitzung.personId)?.stand ??
|
||||
null,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Wie viele Termine eine Serie ergäbe.
|
||||
*
|
||||
* Wird beim Tippen aufgerufen, damit im Formular steht: „Das legt 52
|
||||
* Termine an." Niemand soll überrascht werden.
|
||||
*/
|
||||
serienvorschau: mitRecht("termin.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
beginn: z.date(),
|
||||
muster: z.nativeEnum(Serienmuster),
|
||||
serieBis: z.date(),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) => {
|
||||
const termine = serientermine({
|
||||
beginn: input.beginn,
|
||||
muster: input.muster,
|
||||
bis: input.serieBis,
|
||||
});
|
||||
return {
|
||||
anzahl: termine.length,
|
||||
erster: termine[0] ?? null,
|
||||
letzter: termine.at(-1) ?? null,
|
||||
gekappt: termine.length >= HOECHSTZAHL,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Termin anlegen — einzeln oder als Serie.
|
||||
*
|
||||
* Eine Serie wird ausgeschrieben: jeder Abend bekommt einen eigenen
|
||||
* Datensatz. Warum, steht in lib/termin/serie.ts.
|
||||
*/
|
||||
anlegen: mitRecht("termin.bearbeiten")
|
||||
.input(
|
||||
terminFelder.extend({
|
||||
serienmuster: z.nativeEnum(Serienmuster).default(Serienmuster.KEINE),
|
||||
/** Nur bei einer Serie: bis wann sie läuft. */
|
||||
serieBis: z.date().nullish(),
|
||||
/** Die Angemeldeten sofort benachrichtigen? Bei einer Serie nur einmal. */
|
||||
benachrichtigen: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { serienmuster, serieBis, benachrichtigen, ...felder } = input;
|
||||
|
||||
const problem = widersprueche(felder);
|
||||
if (problem) throw new TRPCError({ code: "BAD_REQUEST", message: problem });
|
||||
|
||||
if (serienmuster !== Serienmuster.KEINE && !serieBis) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Zu einer Serie gehört ein Datum, bis zu dem sie läuft.",
|
||||
});
|
||||
}
|
||||
|
||||
const zeitpunkte =
|
||||
serienmuster === Serienmuster.KEINE
|
||||
? [felder.beginn]
|
||||
: serientermine({ beginn: felder.beginn, muster: serienmuster, bis: serieBis! });
|
||||
|
||||
// Die Dauer bleibt über die Serie hinweg gleich; das Ende wandert mit.
|
||||
const dauer = felder.ende ? felder.ende.getTime() - felder.beginn.getTime() : null;
|
||||
const vorlaufAnmeldeschluss = felder.anmeldeschluss
|
||||
? felder.beginn.getTime() - felder.anmeldeschluss.getTime()
|
||||
: null;
|
||||
|
||||
return ctx.db(async (db) => {
|
||||
const erster = await db.termin.create({
|
||||
data: {
|
||||
...felder,
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
serienmuster,
|
||||
},
|
||||
select: { id: true, titel: true, art: true, ort: true, beginn: true },
|
||||
});
|
||||
|
||||
// Der erste Termin ist der Anker der Serie: seine Kennung wird zur
|
||||
// Serienkennung. So findet man von jedem Abend aus alle anderen.
|
||||
if (zeitpunkte.length > 1) {
|
||||
await db.termin.update({ where: { id: erster.id }, data: { serieId: erster.id } });
|
||||
|
||||
await db.termin.createMany({
|
||||
data: zeitpunkte.slice(1).map((zeitpunkt) => ({
|
||||
...felder,
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
beginn: zeitpunkt,
|
||||
ende: dauer === null ? null : new Date(zeitpunkt.getTime() + dauer),
|
||||
anmeldeschluss:
|
||||
vorlaufAnmeldeschluss === null
|
||||
? null
|
||||
: new Date(zeitpunkt.getTime() - vorlaufAnmeldeschluss),
|
||||
serieId: erster.id,
|
||||
serienmuster,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Termin",
|
||||
entitaetId: erster.id,
|
||||
beschreibung:
|
||||
zeitpunkte.length > 1
|
||||
? `Serie "${felder.titel}" mit ${zeitpunkte.length} Terminen angelegt`
|
||||
: `Termin "${felder.titel}" angelegt`,
|
||||
nachher: { ...felder, serienmuster, anzahl: zeitpunkte.length },
|
||||
});
|
||||
|
||||
if (benachrichtigen) {
|
||||
await benachrichtigungenAnlegenIn(db, ctx.sitzung.organisationId, {
|
||||
ereignis: EreignisTyp.TERMIN_NEU,
|
||||
werte: terminwerte({ ...erster, leiter: null }),
|
||||
entitaet: "Termin",
|
||||
entitaetId: erster.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { id: erster.id, angelegt: zeitpunkte.length };
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Termin ändern.
|
||||
*
|
||||
* Ändert bewusst nur diesen einen Abend, auch wenn er zu einer Serie gehört.
|
||||
* Der Fall „alle folgenden auch" ist selten und mehrdeutig — wer die ganze
|
||||
* Serie verschieben will, legt sie neu an.
|
||||
*/
|
||||
aendern: mitRecht("termin.bearbeiten")
|
||||
.input(terminFelder.partial().extend({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...felder } = input;
|
||||
|
||||
return ctx.db(async (db) => {
|
||||
const vorher = await db.termin.findUnique({ where: { id } });
|
||||
if (!vorher || vorher.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
|
||||
const zusammen = { ...vorher, ...felder } as z.infer<typeof terminFelder>;
|
||||
const problem = widersprueche(zusammen);
|
||||
if (problem) throw new TRPCError({ code: "BAD_REQUEST", message: problem });
|
||||
|
||||
const nachher = await db.termin.update({ where: { id }, data: felder });
|
||||
|
||||
// protokolliereIn bildet selbst die Differenz — es landen nur die
|
||||
// Felder im Protokoll, die sich tatsächlich geändert haben.
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Termin",
|
||||
entitaetId: id,
|
||||
beschreibung: `Termin "${nachher.titel}" geändert`,
|
||||
vorher: vorher as unknown as Record<string, unknown>,
|
||||
nachher: nachher as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Termin absagen.
|
||||
*
|
||||
* Mit Grund, und die Angemeldeten erfahren es. Ein Termin, der ohne ein Wort
|
||||
* verschwindet, sorgt dafür, dass am Dienstagabend jemand vor dem
|
||||
* verschlossenen Gerätehaus steht.
|
||||
*/
|
||||
absagen: mitRecht("termin.bearbeiten")
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
grund: z.string().trim().min(1).max(500),
|
||||
/** Auch alle folgenden Termine derselben Serie absagen. */
|
||||
ganzeSerie: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const termin = await db.termin.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
leiter: { select: { vorname: true, nachname: true } },
|
||||
anmeldungen: { select: { personId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!termin || termin.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
if (termin.abgesagtAm) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Der Termin ist bereits abgesagt." });
|
||||
}
|
||||
|
||||
const jetzt = new Date();
|
||||
|
||||
const betroffen =
|
||||
input.ganzeSerie && termin.serieId
|
||||
? await db.termin.findMany({
|
||||
where: {
|
||||
serieId: termin.serieId,
|
||||
abgesagtAm: null,
|
||||
geloeschtAm: null,
|
||||
// Vergangene Termine bleiben, wie sie waren — man sagt nicht
|
||||
// rückwirkend ab, was schon stattgefunden hat.
|
||||
beginn: { gte: termin.beginn },
|
||||
},
|
||||
select: { id: true, anmeldungen: { select: { personId: true } } },
|
||||
})
|
||||
: [{ id: termin.id, anmeldungen: termin.anmeldungen }];
|
||||
|
||||
await db.termin.updateMany({
|
||||
where: { id: { in: betroffen.map((eintrag) => eintrag.id) } },
|
||||
data: { abgesagtAm: jetzt, absageGrund: input.grund },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Termin",
|
||||
entitaetId: termin.id,
|
||||
beschreibung:
|
||||
betroffen.length > 1
|
||||
? `${betroffen.length} Termine der Serie "${termin.titel}" abgesagt: ${input.grund}`
|
||||
: `Termin "${termin.titel}" abgesagt: ${input.grund}`,
|
||||
vorher: { abgesagtAm: null },
|
||||
nachher: { abgesagtAm: jetzt, absageGrund: input.grund },
|
||||
});
|
||||
|
||||
// Benachrichtigt werden die Angemeldeten — nicht die ganze Wehr. Wer
|
||||
// nie zugesagt hat, braucht keine Absage.
|
||||
const angemeldete = [
|
||||
...new Set(betroffen.flatMap((e) => e.anmeldungen.map((a) => a.personId))),
|
||||
];
|
||||
|
||||
if (angemeldete.length > 0) {
|
||||
await benachrichtigungenAnlegenIn(db, ctx.sitzung.organisationId, {
|
||||
ereignis: EreignisTyp.TERMIN_ABGESAGT,
|
||||
werte: { ...terminwerte(termin), grund: input.grund },
|
||||
personen: angemeldete,
|
||||
entitaet: "Termin",
|
||||
entitaetId: termin.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { abgesagt: betroffen.length, benachrichtigt: angemeldete.length };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Einen Termin entfernen, den es nie hätte geben sollen. Soft-Delete. */
|
||||
loeschen: mitRecht("termin.bearbeiten")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const termin = await db.termin.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { titel: true, beginn: true, _count: { select: { anmeldungen: true } } },
|
||||
});
|
||||
|
||||
if (!termin) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
|
||||
// Wo schon jemand zugesagt hat, wird abgesagt statt gelöscht. Sonst
|
||||
// verschwindet der Termin aus dem Kalender derer, die kommen wollten.
|
||||
if (termin._count.anmeldungen > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Zu diesem Termin liegen bereits Rückmeldungen vor. Bitte absagen statt löschen — " +
|
||||
"dann sehen die Angemeldeten den Grund.",
|
||||
});
|
||||
}
|
||||
|
||||
await db.termin.update({ where: { id: input.id }, data: { geloeschtAm: new Date() } });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Termin",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Termin "${termin.titel}" gelöscht`,
|
||||
vorher: { titel: termin.titel, beginn: termin.beginn },
|
||||
});
|
||||
|
||||
return { erfolgreich: true };
|
||||
}),
|
||||
),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anmeldung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zu- oder Absage der eigenen Person.
|
||||
*
|
||||
* Bewusst `angemeldet` statt eines eigenen Rechts: für sich selbst
|
||||
* zurückzumelden ist keine Berechtigung, die man verleihen müsste. Wer im
|
||||
* System ist, gehört zur Wehr und darf sagen, ob er kommt.
|
||||
*/
|
||||
rueckmelden: angemeldet
|
||||
.input(
|
||||
z.object({
|
||||
terminId: z.string().uuid(),
|
||||
stand: z.enum([Teilnahmestand.ZUGESAGT, Teilnahmestand.ABGESAGT]),
|
||||
grund: z.string().trim().max(300).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const termin = await db.termin.findUnique({
|
||||
where: { id: input.terminId },
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
beginn: true,
|
||||
abgesagtAm: true,
|
||||
geloeschtAm: true,
|
||||
teilnehmerLimit: true,
|
||||
anmeldeschluss: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!termin || termin.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
if (termin.abgesagtAm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Der Termin ist abgesagt — eine Rückmeldung ändert daran nichts.",
|
||||
});
|
||||
}
|
||||
if (termin.anmeldeschluss && termin.anmeldeschluss.getTime() < Date.now()) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Der Anmeldeschluss war am ${alsDatum(termin.anmeldeschluss)}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const bisher = await db.terminanmeldung.findUnique({
|
||||
where: { terminId_personId: { terminId: termin.id, personId: ctx.sitzung.personId } },
|
||||
select: { id: true, stand: true },
|
||||
});
|
||||
|
||||
let stand: Teilnahmestand = input.stand;
|
||||
|
||||
// Bei einer Zusage entscheidet die Platzzahl, ob es eine Zusage wird
|
||||
// oder ein Platz auf der Warteliste. Wer schon zugesagt hatte, behält
|
||||
// seinen Platz — sonst würde ein versehentliches Doppelklicken jemanden
|
||||
// ans Ende der Liste befördern.
|
||||
if (input.stand === Teilnahmestand.ZUGESAGT && termin.teilnehmerLimit) {
|
||||
if (bisher?.stand !== Teilnahmestand.ZUGESAGT) {
|
||||
const zugesagt = await db.terminanmeldung.count({
|
||||
where: { terminId: termin.id, stand: Teilnahmestand.ZUGESAGT },
|
||||
});
|
||||
if (zugesagt >= termin.teilnehmerLimit) stand = Teilnahmestand.WARTELISTE;
|
||||
}
|
||||
}
|
||||
|
||||
const eintrag = await db.terminanmeldung.upsert({
|
||||
where: { terminId_personId: { terminId: termin.id, personId: ctx.sitzung.personId } },
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
terminId: termin.id,
|
||||
personId: ctx.sitzung.personId,
|
||||
stand,
|
||||
grund: input.grund ?? null,
|
||||
},
|
||||
update: { stand, grund: input.grund ?? null },
|
||||
});
|
||||
|
||||
// Wird ein Platz frei, rückt der Erste der Warteliste nach — sofort,
|
||||
// nicht wenn jemand daran denkt.
|
||||
let nachgerueckt: string | null = null;
|
||||
if (
|
||||
input.stand === Teilnahmestand.ABGESAGT &&
|
||||
bisher?.stand === Teilnahmestand.ZUGESAGT &&
|
||||
termin.teilnehmerLimit
|
||||
) {
|
||||
const naechster = await db.terminanmeldung.findFirst({
|
||||
where: { terminId: termin.id, stand: Teilnahmestand.WARTELISTE },
|
||||
orderBy: { gemeldetAm: "asc" },
|
||||
select: { id: true, personId: true },
|
||||
});
|
||||
|
||||
if (naechster) {
|
||||
await db.terminanmeldung.update({
|
||||
where: { id: naechster.id },
|
||||
data: { stand: Teilnahmestand.ZUGESAGT },
|
||||
});
|
||||
nachgerueckt = naechster.personId;
|
||||
|
||||
await benachrichtigungenAnlegenIn(db, ctx.sitzung.organisationId, {
|
||||
ereignis: EreignisTyp.TERMIN_ERINNERUNG,
|
||||
werte: {
|
||||
titel: `Nachgerückt: ${termin.titel}`,
|
||||
datum: alsDatum(termin.beginn),
|
||||
uhrzeit: alsUhrzeit(termin.beginn),
|
||||
tage: Math.max(
|
||||
0,
|
||||
Math.round((termin.beginn.getTime() - Date.now()) / 86_400_000),
|
||||
),
|
||||
},
|
||||
personen: [naechster.personId],
|
||||
entitaet: "Termin",
|
||||
entitaetId: termin.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: bisher ? AuditAktion.GEAENDERT : AuditAktion.ERSTELLT,
|
||||
entitaet: "Terminanmeldung",
|
||||
entitaetId: eintrag.id,
|
||||
beschreibung: `Rückmeldung zu "${termin.titel}": ${stand.toLowerCase()}`,
|
||||
vorher: bisher ? { stand: bisher.stand } : null,
|
||||
nachher: { stand, grund: input.grund ?? null },
|
||||
});
|
||||
|
||||
return { stand, nachgerueckt: Boolean(nachgerueckt) };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Anwesenheit abhaken.
|
||||
*
|
||||
* Passiert nach dem Termin, meist am Tablet im Gerätehaus. Getrennt von der
|
||||
* Zusage, weil beides Verschiedenes ist: wer zugesagt hat und nicht kam,
|
||||
* soll genau so in der Liste stehen.
|
||||
*/
|
||||
anwesenheit: mitRecht("termin.anwesenheit")
|
||||
.input(
|
||||
z.object({
|
||||
terminId: z.string().uuid(),
|
||||
eintraege: z
|
||||
.array(z.object({ personId: z.string().uuid(), anwesend: z.boolean() }))
|
||||
.min(1)
|
||||
.max(500),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const termin = await db.termin.findUnique({
|
||||
where: { id: input.terminId },
|
||||
select: { titel: true, beginn: true },
|
||||
});
|
||||
|
||||
if (!termin) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
|
||||
for (const eintrag of input.eintraege) {
|
||||
await db.terminanmeldung.upsert({
|
||||
where: {
|
||||
terminId_personId: { terminId: input.terminId, personId: eintrag.personId },
|
||||
},
|
||||
// Wer ohne Zusage kam, wird beim Abhaken angelegt — das ist der
|
||||
// Normalfall beim Übungsabend, zu dem sich niemand anmeldet.
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
terminId: input.terminId,
|
||||
personId: eintrag.personId,
|
||||
stand: eintrag.anwesend ? Teilnahmestand.ZUGESAGT : Teilnahmestand.ABGESAGT,
|
||||
anwesend: eintrag.anwesend,
|
||||
},
|
||||
update: { anwesend: eintrag.anwesend },
|
||||
});
|
||||
}
|
||||
|
||||
const anwesend = input.eintraege.filter((eintrag) => eintrag.anwesend).length;
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
sitzung: ctx.sitzung,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Termin",
|
||||
entitaetId: input.terminId,
|
||||
beschreibung: `Anwesenheit zu "${termin.titel}": ${anwesend} von ${input.eintraege.length}`,
|
||||
nachher: { anwesend, gemeldet: input.eintraege.length },
|
||||
});
|
||||
|
||||
return { erfasst: input.eintraege.length, anwesend };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Die Anwesenheitsliste zum Ausdrucken.
|
||||
*
|
||||
* Es gibt Wehren, die den Ausdruck unterschreiben lassen und abheften — das
|
||||
* ist bei Lehrgängen und Arbeitsdiensten teils vorgeschrieben. Deshalb
|
||||
* liefert diese Abfrage **alle aktiven Mitglieder**, nicht nur die
|
||||
* Angemeldeten: auf einem Blatt zum Abhaken muss jeder stehen.
|
||||
*/
|
||||
anwesenheitsliste: mitRecht("termin.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const termin = await db.termin.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { leiter: { select: { vorname: true, nachname: true, dienstgrad: true } } },
|
||||
});
|
||||
|
||||
if (!termin) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
|
||||
const [personen, anmeldungen] = await Promise.all([
|
||||
db.person.findMany({
|
||||
where: { aktiv: true, geloeschtAm: null },
|
||||
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
|
||||
select: { id: true, vorname: true, nachname: true, dienstgrad: true },
|
||||
}),
|
||||
db.terminanmeldung.findMany({
|
||||
where: { terminId: input.id },
|
||||
select: { personId: true, stand: true, anwesend: true, grund: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const jePerson = new Map(anmeldungen.map((eintrag) => [eintrag.personId, eintrag]));
|
||||
|
||||
return {
|
||||
termin,
|
||||
zeilen: personen.map((person) => ({
|
||||
...person,
|
||||
stand: jePerson.get(person.id)?.stand ?? null,
|
||||
anwesend: jePerson.get(person.id)?.anwesend ?? null,
|
||||
grund: jePerson.get(person.id)?.grund ?? null,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { EreignisTyp } from "@prisma/client";
|
||||
import { fuerOrganisation, ohneMandantenfilter } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { benachrichtigungenAnlegenIn, offeneZustellen } from "@/lib/benachrichtigung/versand";
|
||||
import { alsDatum, alsUhrzeit } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Der Teil der Benachrichtigungen, der von selbst passiert.
|
||||
*
|
||||
* Drei Läufe mit verschiedenen Rhythmen:
|
||||
*
|
||||
* zustellen jede Minute — schickt weg, was aussteht
|
||||
* erinnerungen einmal am Morgen — legt an, woran zu erinnern ist
|
||||
* aufraeumen nachts — wirft weg, was seine Zeit hinter sich hat
|
||||
*
|
||||
* Warum getrennt: Zustellen muss oft laufen, damit eine Absage am
|
||||
* Dienstagabend noch ankommt. Erinnerungen dürfen nur einmal täglich laufen,
|
||||
* sonst gäbe es sie stündlich. Und Aufräumen darf ruhig ausfallen.
|
||||
*/
|
||||
|
||||
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
|
||||
console.info(`[benachrichtigung] ${nachricht}`, ...weiteres);
|
||||
}
|
||||
|
||||
/** Der Zustelllauf. Dünne Hülle um lib/benachrichtigung/versand.ts. */
|
||||
export async function zustellen(organisationId: string) {
|
||||
return offeneZustellen(organisationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Tage vor einem Termin erinnert wird.
|
||||
*
|
||||
* Aus TERMIN_ERINNERUNG_TAGE, standardmäßig „7,1": einmal in der Woche davor,
|
||||
* damit man den Abend freihalten kann, und einmal am Vortag, weil man es bis
|
||||
* dahin wieder vergessen hat.
|
||||
*/
|
||||
export function erinnerungsTage(): number[] {
|
||||
return env.TERMIN_ERINNERUNG_TAGE.split(",")
|
||||
.map((teil) => Number(teil.trim()))
|
||||
.filter((zahl) => Number.isInteger(zahl) && zahl >= 0)
|
||||
.sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erinnerungen an anstehende Termine.
|
||||
*
|
||||
* Erinnert wird an alle kommenden Termine, deren Abstand zu heute einer der
|
||||
* eingestellten Vorlaufzeiten entspricht — und nur an die, bei denen es noch
|
||||
* nicht passiert ist. `erinnertAm` am Termin verhindert die Wiederholung; ein
|
||||
* zweiter Lauf am selben Tag verschickt nichts.
|
||||
*
|
||||
* Wer benachrichtigt wird, entscheidet der Termin: bei einem mit Anmeldung
|
||||
* sind es die Angemeldeten, sonst alle, die Terminerinnerungen abonniert
|
||||
* haben. Beim Lehrgang mit zwölf Plätzen will nicht die halbe Wehr eine
|
||||
* Erinnerung; beim Übungsabend schon.
|
||||
*/
|
||||
export async function terminerinnerungen(
|
||||
organisationId: string,
|
||||
heute = new Date(),
|
||||
): Promise<{ termine: number; benachrichtigungen: number }> {
|
||||
const tage = erinnerungsTage();
|
||||
if (tage.length === 0) return { termine: 0, benachrichtigungen: 0 };
|
||||
|
||||
// Ein Tagesfenster je Vorlaufzeit: „in 7 Tagen" heißt alles, was am
|
||||
// siebten Tag ab heute beginnt — von 00:00 bis 24:00 Ortszeit.
|
||||
const fenster = tage.map((abstand) => {
|
||||
const von = new Date(heute);
|
||||
von.setHours(0, 0, 0, 0);
|
||||
von.setDate(von.getDate() + abstand);
|
||||
const bis = new Date(von);
|
||||
bis.setDate(bis.getDate() + 1);
|
||||
return { abstand, von, bis };
|
||||
});
|
||||
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
let termine = 0;
|
||||
let benachrichtigungen = 0;
|
||||
|
||||
for (const { abstand, von, bis } of fenster) {
|
||||
const anstehend = await db.termin.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
abgesagtAm: null,
|
||||
erinnertAm: null,
|
||||
beginn: { gte: von, lt: bis },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
art: true,
|
||||
ort: true,
|
||||
beginn: true,
|
||||
anmeldungNoetig: true,
|
||||
anmeldungen: { select: { personId: true } },
|
||||
organisation: { select: { kurzname: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const termin of anstehend) {
|
||||
const empfaenger = termin.anmeldungNoetig
|
||||
? termin.anmeldungen.map((eintrag) => eintrag.personId)
|
||||
: undefined;
|
||||
|
||||
// Ein Termin mit Anmeldepflicht, zu dem sich niemand gemeldet hat,
|
||||
// braucht keine Erinnerung — aber er gilt als erledigt, sonst
|
||||
// versucht es der Lauf morgen wieder.
|
||||
if (empfaenger && empfaenger.length === 0) {
|
||||
await db.termin.update({ where: { id: termin.id }, data: { erinnertAm: new Date() } });
|
||||
continue;
|
||||
}
|
||||
|
||||
const angelegt = await benachrichtigungenAnlegenIn(db, organisationId, {
|
||||
ereignis: EreignisTyp.TERMIN_ERINNERUNG,
|
||||
werte: {
|
||||
wehr: termin.organisation.kurzname,
|
||||
titel: termin.titel,
|
||||
art: termin.art,
|
||||
datum: alsDatum(termin.beginn),
|
||||
uhrzeit: alsUhrzeit(termin.beginn),
|
||||
ort: termin.ort,
|
||||
tage: abstand,
|
||||
},
|
||||
personen: empfaenger,
|
||||
entitaet: "Termin",
|
||||
entitaetId: termin.id,
|
||||
});
|
||||
|
||||
await db.termin.update({ where: { id: termin.id }, data: { erinnertAm: new Date() } });
|
||||
|
||||
termine += 1;
|
||||
benachrichtigungen += angelegt;
|
||||
}
|
||||
}
|
||||
|
||||
if (termine > 0) {
|
||||
protokoll(`${termine} Termine erinnert, ${benachrichtigungen} Nachrichten angelegt`);
|
||||
}
|
||||
|
||||
return { termine, benachrichtigungen };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ablaufende Qualifikationen.
|
||||
*
|
||||
* Die Erinnerung geht an die Person selbst, nicht an den Kommandanten: die
|
||||
* G 26.3 nachholen muss der Träger, und er ist derjenige, der sich einen
|
||||
* Termin beim Arzt geben lassen muss.
|
||||
*
|
||||
* Erinnert wird bei 90, 30 und 7 Tagen. Neunzig Tage klingen weit weg, sind
|
||||
* es aber nicht — auf einen Untersuchungstermin wartet man mancherorts
|
||||
* zwei Monate.
|
||||
*/
|
||||
const QUALIFIKATION_VORLAUF = [90, 30, 7];
|
||||
|
||||
export async function qualifikationsablauf(
|
||||
organisationId: string,
|
||||
heute = new Date(),
|
||||
): Promise<{ qualifikationen: number; benachrichtigungen: number }> {
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
let qualifikationen = 0;
|
||||
let benachrichtigungen = 0;
|
||||
|
||||
for (const abstand of QUALIFIKATION_VORLAUF) {
|
||||
const von = new Date(heute);
|
||||
von.setHours(0, 0, 0, 0);
|
||||
von.setDate(von.getDate() + abstand);
|
||||
const bis = new Date(von);
|
||||
bis.setDate(bis.getDate() + 1);
|
||||
|
||||
const ablaufend = await db.qualifikation.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
gueltigBis: { gte: von, lt: bis },
|
||||
person: { aktiv: true, geloeschtAm: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
gueltigBis: true,
|
||||
person: { select: { id: true, vorname: true, nachname: true } },
|
||||
typ: { select: { name: true } },
|
||||
organisation: { select: { kurzname: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const eintrag of ablaufend) {
|
||||
const angelegt = await benachrichtigungenAnlegenIn(db, organisationId, {
|
||||
ereignis: EreignisTyp.QUALIFIKATION_LAEUFT_AB,
|
||||
werte: {
|
||||
wehr: eintrag.organisation.kurzname,
|
||||
person: `${eintrag.person.vorname} ${eintrag.person.nachname}`,
|
||||
qualifikation: eintrag.typ?.name ?? "Qualifikation",
|
||||
gueltigBis: eintrag.gueltigBis ? alsDatum(eintrag.gueltigBis) : "—",
|
||||
tage: abstand,
|
||||
},
|
||||
personen: [eintrag.person.id],
|
||||
entitaet: "Qualifikation",
|
||||
entitaetId: eintrag.id,
|
||||
});
|
||||
|
||||
qualifikationen += 1;
|
||||
benachrichtigungen += angelegt;
|
||||
}
|
||||
}
|
||||
|
||||
if (qualifikationen > 0) {
|
||||
protokoll(
|
||||
`${qualifikationen} ablaufende Qualifikationen, ${benachrichtigungen} Nachrichten angelegt`,
|
||||
);
|
||||
}
|
||||
|
||||
return { qualifikationen, benachrichtigungen };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zugestellte Benachrichtigungen abräumen.
|
||||
*
|
||||
* Was ankam, ist nach BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE erledigt.
|
||||
* **Fehlgeschlagene bleiben** — sie sind der Hinweis darauf, dass ein Kanal
|
||||
* kaputt ist, und der verliert seinen Wert nicht durch Zeitablauf.
|
||||
*
|
||||
* Der Lauf braucht den Eigentümerzugang: der Anwendungsrolle ist DELETE auf
|
||||
* dieser Tabelle entzogen (siehe Migration), damit niemand über die Oberfläche
|
||||
* wegräumen kann, wer wann worüber informiert wurde. Das Aufräumen nach Frist
|
||||
* ist der einzige zulässige Grund — und deshalb steht der Mandantenfilter hier
|
||||
* ausdrücklich in der Bedingung, nicht in der Verbindung.
|
||||
*/
|
||||
export async function benachrichtigungenAufraeumen(
|
||||
organisationId: string,
|
||||
heute = new Date(),
|
||||
): Promise<{ entfernt: number }> {
|
||||
const grenze = new Date(heute.getTime() - env.BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE * 864e5);
|
||||
|
||||
const entfernt = await ohneMandantenfilter(
|
||||
"Aufbewahrungsfrist für zugestellte Benachrichtigungen — DELETE ist der App-Rolle entzogen",
|
||||
async (db) => {
|
||||
const ergebnis = await db.benachrichtigung.deleteMany({
|
||||
where: { organisationId, stand: "GESENDET", gesendetAm: { lt: grenze } },
|
||||
});
|
||||
return ergebnis.count;
|
||||
},
|
||||
);
|
||||
|
||||
if (entfernt > 0) protokoll(`${entfernt} zugestellte Benachrichtigungen abgeräumt`);
|
||||
|
||||
return { entfernt };
|
||||
}
|
||||
|
||||
/** Der Morgenlauf: erst anlegen, dann gleich zustellen. */
|
||||
export async function morgenlauf(organisationId: string) {
|
||||
const termine = await terminerinnerungen(organisationId);
|
||||
const qualifikationen = await qualifikationsablauf(organisationId);
|
||||
const zugestellt = await offeneZustellen(organisationId);
|
||||
|
||||
return { termine, qualifikationen, zugestellt };
|
||||
}
|
||||
+53
-1
@@ -16,6 +16,11 @@ import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
|
||||
import { einsatzberichtErzeugen } from "@/lib/einsatz/bericht-erzeugen";
|
||||
import { atemschutzprotokollErzeugen } from "@/lib/atemschutz/protokoll-erzeugen";
|
||||
import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung";
|
||||
import {
|
||||
benachrichtigungenAufraeumen,
|
||||
morgenlauf,
|
||||
zustellen,
|
||||
} from "@/worker/benachrichtigung";
|
||||
|
||||
/**
|
||||
* Hintergrundprozess.
|
||||
@@ -133,11 +138,44 @@ async function atemschutzprotokollErzeugenAuftrag(auftrag: Job<AuftragsDaten>) {
|
||||
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Zustelllauf.
|
||||
*
|
||||
* Läuft jede Minute und ist deshalb bewusst wortkarg: er meldet sich nur,
|
||||
* wenn er etwas getan hat. Ein Worker, der 1440-mal am Tag „nichts zu tun"
|
||||
* ins Protokoll schreibt, macht das Protokoll unlesbar.
|
||||
*/
|
||||
async function benachrichtigungZustellen(auftrag: Job<AuftragsDaten>) {
|
||||
const ergebnis = await zustellen(auftrag.data.organisationId);
|
||||
if (ergebnis.gesendet + ergebnis.aufgegeben > 0) {
|
||||
protokoll(
|
||||
`Benachrichtigungen: ${ergebnis.gesendet} zugestellt, ${ergebnis.aufgegeben} aufgegeben`,
|
||||
);
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/** Der Morgenlauf: Terminerinnerungen, ablaufende Qualifikationen, Zustellung. */
|
||||
async function benachrichtigungErinnern(auftrag: Job<AuftragsDaten>) {
|
||||
const ergebnis = await morgenlauf(auftrag.data.organisationId);
|
||||
protokoll(
|
||||
`Morgenlauf: ${ergebnis.termine.termine} Termine, ` +
|
||||
`${ergebnis.qualifikationen.qualifikationen} Qualifikationen, ` +
|
||||
`${ergebnis.zugestellt.gesendet} Nachrichten zugestellt`,
|
||||
);
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
|
||||
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
|
||||
// Seit Phase 7: zugestellte Benachrichtigungen nach ihrer Frist abräumen.
|
||||
// Fehlgeschlagene bleiben — sie zeigen einen kaputten Kanal an.
|
||||
const benachrichtigungen = await benachrichtigungenAufraeumen(auftrag.data.organisationId);
|
||||
|
||||
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
|
||||
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
|
||||
return { entfernt: 0 };
|
||||
return { entfernt: benachrichtigungen.entfernt };
|
||||
}
|
||||
|
||||
const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknown>> = {
|
||||
@@ -146,6 +184,8 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
|
||||
[AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen,
|
||||
[AUFTRAG.EINSATZBERICHT_ERZEUGEN]: einsatzberichtErzeugenAuftrag,
|
||||
[AUFTRAG.ATEMSCHUTZPROTOKOLL_ERZEUGEN]: atemschutzprotokollErzeugenAuftrag,
|
||||
[AUFTRAG.BENACHRICHTIGUNG_ZUSTELLEN]: benachrichtigungZustellen,
|
||||
[AUFTRAG.BENACHRICHTIGUNG_ERINNERN]: benachrichtigungErinnern,
|
||||
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
|
||||
};
|
||||
|
||||
@@ -174,6 +214,18 @@ async function zeitplaeneEinrichten(): Promise<number> {
|
||||
{ organisationId: organisation.id },
|
||||
env.WARTUNG_ERINNERUNG_LAUF,
|
||||
);
|
||||
// Jede Minute. Eine Absage am Dienstagabend muss ankommen, solange sie
|
||||
// noch etwas nützt — nicht am nächsten Morgen.
|
||||
await beauftrageWiederkehrend(
|
||||
AUFTRAG.BENACHRICHTIGUNG_ZUSTELLEN,
|
||||
{ organisationId: organisation.id },
|
||||
"* * * * *",
|
||||
);
|
||||
await beauftrageWiederkehrend(
|
||||
AUFTRAG.BENACHRICHTIGUNG_ERINNERN,
|
||||
{ organisationId: organisation.id },
|
||||
env.BENACHRICHTIGUNG_LAUF,
|
||||
);
|
||||
}
|
||||
|
||||
return organisationen.length;
|
||||
|
||||
+90
-15
@@ -6,6 +6,8 @@ import { abgeleiteterGeraetStatus } from "@/lib/stammdaten/fristen";
|
||||
import { erinnerungsstufe, type Erinnerungsstufe } from "@/lib/wartung/fristen";
|
||||
import { fruehesteFaelligkeit, zuPlanendeTermine, type PlanungsGeraet } from "@/lib/wartung/planung";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
import { EreignisTyp } from "@prisma/client";
|
||||
import { benachrichtigungenAnlegenIn } from "@/lib/benachrichtigung/versand";
|
||||
|
||||
/**
|
||||
* Die nächtliche Arbeit hinter dem Prüfmanagement.
|
||||
@@ -225,6 +227,8 @@ interface Faellig {
|
||||
bezeichnung: string;
|
||||
vorlage: string;
|
||||
empfaenger: string[];
|
||||
/** Wer die Sammelmail bekommt — die sollen nicht zusätzlich einen Push kriegen. */
|
||||
empfaengerIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,7 +253,7 @@ export async function erinnerungenSenden(
|
||||
faelligAm: true,
|
||||
geraet: { select: { inventarnummer: true, bezeichnung: true } },
|
||||
vorlage: { select: { name: true, vorwarnzeitTage: true } },
|
||||
zugewiesenAn: { select: { email: true, aktiv: true } },
|
||||
zugewiesenAn: { select: { id: true, email: true, aktiv: true } },
|
||||
erinnerungen: { select: { stufe: true } },
|
||||
},
|
||||
take: 5000,
|
||||
@@ -267,12 +271,13 @@ export async function erinnerungenSenden(
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { email: true },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
const ersatz = geraetewarte
|
||||
.map((person) => person.email)
|
||||
.filter((adresse): adresse is string => Boolean(adresse));
|
||||
const ersatzIds = geraetewarte.map((person) => person.id);
|
||||
|
||||
const offen: Faellig[] = [];
|
||||
|
||||
@@ -287,10 +292,10 @@ export async function erinnerungenSenden(
|
||||
const bereits = new Set(termin.erinnerungen.map((eintrag) => eintrag.stufe));
|
||||
if (bereits.has(stufe)) continue;
|
||||
|
||||
const zustaendig =
|
||||
termin.zugewiesenAn?.aktiv && termin.zugewiesenAn.email
|
||||
? [termin.zugewiesenAn.email]
|
||||
: ersatz;
|
||||
const eigenerEmpfaenger = Boolean(termin.zugewiesenAn?.aktiv && termin.zugewiesenAn.email);
|
||||
|
||||
const zustaendig = eigenerEmpfaenger ? [termin.zugewiesenAn!.email!] : ersatz;
|
||||
const zustaendigIds = eigenerEmpfaenger ? [termin.zugewiesenAn!.id] : ersatzIds;
|
||||
|
||||
offen.push({
|
||||
terminId: termin.id,
|
||||
@@ -300,6 +305,7 @@ export async function erinnerungenSenden(
|
||||
bezeichnung: termin.geraet.bezeichnung,
|
||||
vorlage: termin.vorlage?.name ?? "Prüfung",
|
||||
empfaenger: zustaendig,
|
||||
empfaengerIds: zustaendigIds,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,7 +375,48 @@ export async function erinnerungenSenden(
|
||||
}
|
||||
});
|
||||
|
||||
protokoll(`${faellige.length} Erinnerungen, ${nachrichten} Nachrichten verschickt`);
|
||||
// Zusätzlich zu der Sammelmail: wer sich für „Prüfung fällig" einen eigenen
|
||||
// Zustellweg eingerichtet hat, bekommt eine Benachrichtigung darüber.
|
||||
//
|
||||
// Ausgenommen sind die, die ohnehin die Sammelmail bekommen — sonst hätte
|
||||
// der Gerätewart die Liste zweimal. Die Sammelmail bleibt der führende Weg:
|
||||
// sie geht an den, der zuständig ist, unabhängig davon, ob er in FLORIAN
|
||||
// irgendetwas angekreuzt hat. Ein Prüftermin ist eine Pflicht, kein Abo.
|
||||
const naechste = [...faellige].sort((a, b) => a.faelligAm.getTime() - b.faelligAm.getTime())[0];
|
||||
const direkteEmpfaenger = [...new Set(faellige.flatMap((eintrag) => eintrag.empfaengerIds))];
|
||||
|
||||
let zusaetzlich = 0;
|
||||
if (naechste) {
|
||||
zusaetzlich = await fuerOrganisation(organisationId, async (db) => {
|
||||
const organisation = await db.organisation.findUnique({
|
||||
where: { id: organisationId },
|
||||
select: { kurzname: true },
|
||||
});
|
||||
|
||||
return benachrichtigungenAnlegenIn(db, organisationId, {
|
||||
ereignis: EreignisTyp.PRUEFUNG_FAELLIG,
|
||||
werte: {
|
||||
wehr: organisation?.kurzname ?? "Feuerwehr",
|
||||
anzahl: faellige.length,
|
||||
geraet: naechste.bezeichnung,
|
||||
inventarnummer: naechste.inventarnummer,
|
||||
faelligAm: alsDatum(naechste.faelligAm),
|
||||
tage: Math.max(
|
||||
0,
|
||||
Math.round((naechste.faelligAm.getTime() - heute.getTime()) / 864e5),
|
||||
),
|
||||
},
|
||||
ausschluss: direkteEmpfaenger,
|
||||
entitaet: "Wartungstermin",
|
||||
entitaetId: naechste.terminId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protokoll(
|
||||
`${faellige.length} Erinnerungen, ${nachrichten} Nachrichten verschickt, ` +
|
||||
`${zusaetzlich} zusätzliche Benachrichtigungen angelegt`,
|
||||
);
|
||||
|
||||
return { termine: faellige.length, nachrichten };
|
||||
}
|
||||
@@ -396,12 +443,17 @@ export async function mangelBenachrichtigung(
|
||||
geraetGesperrt: true,
|
||||
melderName: true,
|
||||
geraet: { select: { inventarnummer: true, bezeichnung: true } },
|
||||
zugewiesenAn: { select: { email: true, aktiv: true } },
|
||||
zugewiesenAn: { select: { id: true, email: true, aktiv: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!mangel) return null;
|
||||
|
||||
const organisation = await db.organisation.findUnique({
|
||||
where: { id: organisationId },
|
||||
select: { kurzname: true },
|
||||
});
|
||||
|
||||
const geraetewarte = await db.person.findMany({
|
||||
where: {
|
||||
aktiv: true,
|
||||
@@ -411,15 +463,38 @@ export async function mangelBenachrichtigung(
|
||||
some: { rolle: { schluessel: { in: ["GERAETEWART", "ATEMSCHUTZGERAETEWART"] } } },
|
||||
},
|
||||
},
|
||||
select: { email: true },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
const empfaenger =
|
||||
mangel.zugewiesenAn?.aktiv && mangel.zugewiesenAn.email
|
||||
? [mangel.zugewiesenAn.email]
|
||||
: geraetewarte
|
||||
.map((person) => person.email)
|
||||
.filter((adresse): adresse is string => Boolean(adresse));
|
||||
const eigener = Boolean(mangel.zugewiesenAn?.aktiv && mangel.zugewiesenAn.email);
|
||||
|
||||
const empfaenger = eigener
|
||||
? [mangel.zugewiesenAn!.email!]
|
||||
: geraetewarte
|
||||
.map((person) => person.email)
|
||||
.filter((adresse): adresse is string => Boolean(adresse));
|
||||
|
||||
const empfaengerIds = eigener
|
||||
? [mangel.zugewiesenAn!.id]
|
||||
: geraetewarte.map((person) => person.id);
|
||||
|
||||
// Ein Mangel eilt: die Benachrichtigungen entstehen sofort in derselben
|
||||
// Transaktion, nicht erst im Morgenlauf. Der Zustelllauf holt sie
|
||||
// binnen einer Minute ab.
|
||||
await benachrichtigungenAnlegenIn(db, organisationId, {
|
||||
ereignis: EreignisTyp.MANGEL_GEMELDET,
|
||||
werte: {
|
||||
wehr: organisation?.kurzname ?? "Feuerwehr",
|
||||
geraet: mangel.geraet.bezeichnung,
|
||||
inventarnummer: mangel.geraet.inventarnummer,
|
||||
beschreibung: mangel.beschreibung,
|
||||
prioritaet: mangel.prioritaet,
|
||||
melder: mangel.melderName ?? "unbekannt",
|
||||
},
|
||||
ausschluss: empfaengerIds,
|
||||
entitaet: "Mangel",
|
||||
entitaetId: mangelId,
|
||||
});
|
||||
|
||||
return { mangel, empfaenger };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user