Add educator email invitations and staff notification preferences
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
-- Nur E-Mail-Erweiterung für einen bereits mit 043fed2 eingeführten Personalbereich.
|
||||
-- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
|
||||
BEGIN;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"toAdmins" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"inviteToken" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "staff_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Einmalige additive Änderung gegen Ausgangscommit ec3f4f9.
|
||||
-- Alternativ zum bestehenden prisma db push, nicht danach erneut ausführen.
|
||||
-- Einmalige Einführung des vollständigen Personalbereichs inklusive E-Mails ab ec3f4f9.
|
||||
-- Alternative zu prisma db push, nicht zusätzlich oder mehrfach ausführen.
|
||||
BEGIN;
|
||||
|
||||
-- CreateEnum
|
||||
@@ -11,6 +11,9 @@ CREATE TYPE "StaffLeaveKind" AS ENUM ('VACATION', 'TIME_OFF', 'SICKNESS');
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StaffWeekStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'APPROVED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "staffDecisionEmails" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
@@ -26,6 +29,26 @@ CREATE TABLE "staff_profiles" (
|
||||
CONSTRAINT "staff_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_emails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"toAdmins" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subject" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"inviteToken" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"skippedAt" TIMESTAMP(3),
|
||||
"leaseUntil" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "staff_emails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_work_patterns" (
|
||||
"id" TEXT NOT NULL,
|
||||
@@ -173,6 +196,9 @@ CREATE UNIQUE INDEX "staff_profiles_userId_key" ON "staff_profiles"("userId");
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_profiles_kitaId_idx" ON "staff_profiles"("kitaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_emails_profileId_sentAt_skippedAt_idx" ON "staff_emails"("profileId", "sentAt", "skippedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_work_patterns_profileId_effectiveFrom_key" ON "staff_work_patterns"("profileId", "effectiveFrom");
|
||||
|
||||
@@ -215,6 +241,12 @@ ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_educatorId_fkey" FOR
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_profiles" ADD CONSTRAINT "staff_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_emails" ADD CONSTRAINT "staff_emails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_work_patterns" ADD CONSTRAINT "staff_work_patterns_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "staff_profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ Dokumentationsbranch: `codex/funktionsinventar`
|
||||
|
||||
**Ergänzung vom 07.10.2026:** Auf `codex/personalbereich` wurde anschließend der Personalbereich für Arbeitszeit, Urlaub und Freizeitausgleich umgesetzt. Diese Bestandsaufnahme bleibt als Beschreibung des Ausgangsstands erhalten; der neue Umfang, Berechnungsregeln, Einrichtung und aktuelle Prüfungen stehen in [PERSONALBEREICH.md](PERSONALBEREICH.md). Die Aussagen unten zu fehlender Zeiterfassung, Accountverknüpfung und Tests beziehen sich auf den Ausgangscommit.
|
||||
|
||||
**Ergänzung vom 08.10.2026:** Der Personalbereich enthält jetzt auch Aktivierungsmails, Antrags- und Entscheidungshinweise sowie eine Abschalteinstellung im Fachkraftprofil. Der aktuelle Umfang und die Versandkonfiguration stehen ebenfalls in [PERSONALBEREICH.md](PERSONALBEREICH.md).
|
||||
|
||||
## 1. Grundlage der Prüfung
|
||||
|
||||
Diese Bestandsaufnahme beschreibt die im Repository vorhandenen Produktfunktionen. Geprüft wurden Seiten und Navigation, Formulare und Dialoge, Server Actions, API-Routen, Datenmodell, E-Mail-Anbindung und Betriebsdateien. Die Planung in `planung.md`, die UI-Liste in `UI-TODO.md` und der frühere Live-Testplan wurden mit dem aktuellen Code abgeglichen.
|
||||
|
||||
+27
-20
@@ -1,6 +1,6 @@
|
||||
# Personalbereich: Arbeitszeit, Urlaub und Zeitkonto
|
||||
|
||||
Stand: 07.10.2026 · Branch `codex/personalbereich`
|
||||
Stand: 08.10.2026 · Branch `codex/personalbereich`
|
||||
|
||||
## Einstieg und Zuständigkeiten
|
||||
|
||||
@@ -14,7 +14,7 @@ Bestehende ErzieherInnen-Stammdaten bleiben erhalten. Ein Admin öffnet beim Per
|
||||
4. Einen passenden ErzieherInnen-Account. Bestehende verifizierte Accounts derselben Kita können verknüpft werden. Für neue Accounts wird ein persönlicher, einmal verwendbarer Aktivierungslink mit sieben Tagen Gültigkeit erzeugt. Ein neuer Link ersetzt den alten.
|
||||
5. Die örtlichen Feiertage unter **Teamkalender**. Feiertage gelten separat von den Schließtagen des allgemeinen Kalenders.
|
||||
|
||||
Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Aktivierungslinks werden nur dem berechtigten Admin angezeigt und persönlich weitergegeben. Automatischer E-Mail-Versand ist in dieser Umsetzung nicht angebunden.
|
||||
Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Neue Accounts erhalten den Aktivierungslink per E-Mail; der berechtigte Admin erhält ihn zusätzlich zum Kopieren. Ein vorhandener verifizierter Account wird verknüpft, ohne eine Aktivierungsmail zu verschicken.
|
||||
|
||||
## Arbeitszeit und Überstunden
|
||||
|
||||
@@ -59,6 +59,7 @@ Das Projekt verwendet weiterhin seinen vorhandenen Ablauf mit `prisma db push`.
|
||||
- Lokal: Abhängigkeiten installieren, Prisma-Client generieren, Schema gegen eine dafür vorgesehene Datenbank anwenden, App starten und Profile einrichten.
|
||||
- Im vorhandenen Docker-Ablauf erzeugt `npm run build:prod` den Client; `scripts/start-production.sh` wendet das Schema vor App-Start an.
|
||||
- [Prüfbares SQL](../deploy/sql/personalbereich.sql) beschreibt dieselbe additive Änderung gegen den dokumentierten Ausgangsstand. Es ist einmalig und nicht idempotent. Entweder den vorhandenen Schemaabgleich **oder** dieses SQL verwenden; das SQL nicht nach bereits erfolgtem Schemaabgleich erneut ausführen.
|
||||
- Ist der Personalbereich bereits auf Stand `043fed2` eingeführt, beschreibt [Personalbereich-E-Mail-SQL](../deploy/sql/personalbereich-email.sql) ausschließlich die zusätzlichen Mail-Tabellen und die Profileinstellung. Auch dieses Skript ist eine einmalige Alternative zum Schemaabgleich.
|
||||
|
||||
Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht verändert. Alle DB- und Browserprüfungen liefen mit synthetischen Daten in einer separaten lokalen PostgreSQL-Datenbank.
|
||||
|
||||
@@ -71,27 +72,33 @@ Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht ver
|
||||
|
||||
Ergebnisse der abschließenden Prüfung:
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| Berechnungstests | 9 bestanden. |
|
||||
| Integrationstests | 12 bestanden; Testkitas danach bereinigt. |
|
||||
| TypeScript | Erfolgreich. |
|
||||
| ESLint für sämtliche geänderten Anwendungsteile und neue Tests | Erfolgreich, ohne Warnungen. |
|
||||
| Projektweites ESLint | Die bereits vorhandenen zwei `as any`-Fehler in `src/lib/auth-utils.ts` und neun Warnungen bestehen weiterhin. |
|
||||
| Endgültiger Produktionsbuild | Erfolgreich, einschließlich `/dashboard/personal`. |
|
||||
| SQL-Skript gegen Ausgangsschema | Erfolgreich im isolierten Prüfschema; anschließend kein Unterschied zum Prisma-Schema. |
|
||||
| Browser | Admin- und ErzieherInnen-Ablauf einschließlich Freigaben erfolgreich. Mobile Ansicht und Arbeitszeitdialog ohne Seitenüberlauf geprüft. |
|
||||
| Prüfung | Ergebnis |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Berechnungstests | 9 bestanden. |
|
||||
| Integrationstests | 17 bestanden; einschließlich Empfänger, Opt-out, Versandfehler, Token-Erneuerung und paralleler Versandversuche. Testkitas danach bereinigt. |
|
||||
| TypeScript | Erfolgreich. |
|
||||
| ESLint für sämtliche geänderten Anwendungsteile und neue Tests | Erfolgreich, ohne Warnungen. |
|
||||
| Projektweites ESLint | Die bereits vorhandenen zwei `as any`-Fehler in `src/lib/auth-utils.ts` und neun Warnungen bestehen weiterhin. |
|
||||
| Endgültiger Produktionsbuild | Erfolgreich, einschließlich `/dashboard/personal`. |
|
||||
| Vollständiges und inkrementelles SQL-Skript | Erfolgreich in isolierten Prüfschemata; anschließend jeweils kein Unterschied zum Prisma-Schema. |
|
||||
| Browser | Admin- und ErzieherInnen-Ablauf einschließlich Freigaben erfolgreich. Mobile Ansicht und Arbeitszeitdialog ohne Seitenüberlauf geprüft. Abschalten der E-Mail-Hinweise im Demo-Profil gespeichert und nach Neuladen verifiziert; anschließend wieder eingeschaltet. |
|
||||
|
||||
Die vom bestehenden Build gemeldete Warnung zu mehreren Lockfiles bzw. dem Turbopack-Arbeitsverzeichnis und die bestehende Prisma-Konfigurationswarnung sind unabhängig vom Personalbereich. Eine produktive Bereitstellung oder Mailzustellung wurde nicht durchgeführt.
|
||||
|
||||
## E-Mail-Erweiterung zur gesonderten Freigabe
|
||||
## E-Mail-Hinweise
|
||||
|
||||
Die automatische Freigabeprüfung hat neue ausgehende E-Mails mit Einladungstokens und Personaldaten abgelehnt, weil eine ausdrückliche Zustimmung für diese Daten und Ziele fehlt. Interne Hinweise und Aktivierungslinks sind umgesetzt. Für einen späteren Versand wäre folgender begrenzter Umfang vorgesehen:
|
||||
Die E-Mail-Erweiterung wurde am 08.10.2026 ausdrücklich beauftragt. Sie verwendet die vorhandene Resend-Anbindung:
|
||||
|
||||
| Nachricht | Empfänger | Inhalt |
|
||||
| --- | --- | --- |
|
||||
| Accountaktivierung | Vom Kita-Admin eingetragene E-Mail-Adresse der Fachkraft | Kita, Name, persönlicher Aktivierungslink mit sieben Tagen Gültigkeit. |
|
||||
| Antrag oder Arbeitswoche eingereicht | Account-Adressen der Kita-Admins derselben Kita | Name, Zeitraum und Hinweis auf Prüfung im geschützten Personalbereich. |
|
||||
| Entscheidung / Rückgabe | Account-Adresse der betroffenen Fachkraft | Art, Zeitraum und Status mit Link in den geschützten Personalbereich. |
|
||||
| Nachricht | Empfänger | Inhalt |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Accountaktivierung | Vom Kita-Admin eingetragene E-Mail-Adresse der Fachkraft | Kita, Name, persönlicher Aktivierungslink mit sieben Tagen Gültigkeit. |
|
||||
| Antrag / Änderung / Stornierung oder Arbeitswoche eingereicht | Alle Kita-Admins derselben Kita, einzeln angeschrieben | Name, Zeitraum und Hinweis auf Prüfung im geschützten Personalbereich. |
|
||||
| Entscheidung / Rückgabe | Account-Adresse der betroffenen Fachkraft | Art, Zeitraum und Status mit Link in den geschützten Personalbereich. |
|
||||
|
||||
Krankheitsdetails, private Notizen, Zeit- und Urlaubskontostände würden nicht per E-Mail übertragen. Hierfür wurde kein Live-Versandcode hinzugefügt und keine Nachricht verschickt.
|
||||
Fachkräfte können Entscheidungsmails unter **Mein Profil → E-Mail-Hinweise** abschalten. Aktivierungslinks und interne App-Hinweise bleiben davon unabhängig. Krankheitsbuchungen erzeugen keine E-Mail. Private Notizen, Entscheidungsbegründungen und Zeit- oder Urlaubskontostände werden nicht per E-Mail übertragen.
|
||||
|
||||
Versandaufträge werden innerhalb derselben Datenbanktransaktion wie die Personalaktion gespeichert. Der Versand erfolgt anschließend; ein Fehler macht den gespeicherten Antrag nicht rückgängig. Ausstehende Mails erscheinen im Personalbereich mit **Versand erneut versuchen**. Admins dürfen die Aufträge ihrer Kita wiederholen, Fachkräfte nur eigene. Pro Aktion werden bis zu 50 ausstehende Aufträge verarbeitet. Es gibt keinen zusätzlichen Hintergrundjob.
|
||||
|
||||
Vor jedem Versand werden Kita-Zugehörigkeit, aktuelle Rolle, Accountverknüpfung und gegebenenfalls die Abschalteinstellung geprüft. Ersetzte, abgelaufene oder bereits verwendete Aktivierungslinks werden übersprungen. Eine fünfminütige Versandsperre verhindert parallele Versuche für denselben Auftrag; ein stabiler Resend-Idempotenzschlüssel vermindert doppelte Zustellungen bei Wiederholungen. Eine Zustellbestätigung des Empfängerpostfachs wird nicht erfasst.
|
||||
|
||||
Benötigt werden `RESEND_API_KEY`, `EMAIL_FROM` und eine öffentliche App-Adresse über `NEXT_PUBLIC_SITE_URL`, alternativ `AUTH_URL` oder `NEXTAUTH_URL`. Ohne Konfiguration bleibt ein Produktionsversand ausstehend und wird als Warnung angezeigt. Im Entwicklungsmodus ohne Resend-Schlüssel wird der bestehende lokale Mailtransport simuliert. Die Tests verwenden ausschließlich einen eingesetzten Testtransport; es wurden keine echten E-Mails verschickt.
|
||||
|
||||
@@ -213,6 +213,7 @@ model User {
|
||||
privacyPolicyVersion String?
|
||||
/// Zeitstempel des Opt-Ins für das interne Kita-Adressbuch (Modul 3).
|
||||
directoryOptInAt DateTime?
|
||||
staffDecisionEmails Boolean @default(true)
|
||||
|
||||
// Auth-Bookkeeping
|
||||
emailVerifiedAt DateTime?
|
||||
@@ -243,6 +244,7 @@ model User {
|
||||
auditLogsAuthored AuditLog[] @relation("AuditLogActor")
|
||||
staffProfile StaffProfile?
|
||||
staffNotifications StaffNotification[]
|
||||
staffEmails StaffEmail[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@index([familyId])
|
||||
@@ -348,11 +350,35 @@ model StaffProfile {
|
||||
adjustments StaffAdjustment[]
|
||||
history StaffHistory[]
|
||||
notifications StaffNotification[]
|
||||
emails StaffEmail[]
|
||||
|
||||
@@index([kitaId])
|
||||
@@map("staff_profiles")
|
||||
}
|
||||
|
||||
// Persisted in the same transaction as the staff action; delivered after commit.
|
||||
model StaffEmail {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile StaffProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
toAdmins Boolean @default(false)
|
||||
subject String
|
||||
message String
|
||||
path String
|
||||
inviteToken String?
|
||||
createdAt DateTime @default(now())
|
||||
sentAt DateTime?
|
||||
skippedAt DateTime?
|
||||
leaseUntil DateTime?
|
||||
attempts Int @default(0)
|
||||
failed Boolean @default(false)
|
||||
|
||||
@@index([profileId, sentAt, skippedAt])
|
||||
@@map("staff_emails")
|
||||
}
|
||||
|
||||
model StaffWorkPattern {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
|
||||
@@ -4,11 +4,14 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireStaffActor } from "@/lib/staff/auth";
|
||||
import { executeStaffCommand } from "@/lib/staff/service";
|
||||
import { deliverStaffEmails } from "@/lib/staff/email";
|
||||
|
||||
export type StaffActionResult = {
|
||||
error?: string;
|
||||
success?: boolean;
|
||||
invitePath?: string;
|
||||
warning?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function staffAction(
|
||||
@@ -18,6 +21,28 @@ export async function staffAction(
|
||||
const actor = await requireStaffActor();
|
||||
try {
|
||||
const result = await executeStaffCommand(actor, { command, data });
|
||||
let warning: string | undefined;
|
||||
let message: string | undefined;
|
||||
if (result.emailIds?.length) {
|
||||
try {
|
||||
const delivery = await deliverStaffEmails(result.emailIds);
|
||||
if (delivery.failed)
|
||||
warning =
|
||||
"Gespeichert. Einige E-Mails konnten nicht versendet werden. Im Personalbereich kannst du den Versand erneut versuchen.";
|
||||
else if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!process.env.RESEND_API_KEY
|
||||
)
|
||||
warning = "Gespeichert. Der E-Mail-Versand wurde lokal simuliert.";
|
||||
else if (result.invite && delivery.sent)
|
||||
message = "Aktivierungslink per E-Mail versendet.";
|
||||
else if (command === "emailRetry")
|
||||
message = "Ausstehende E-Mails wurden verarbeitet.";
|
||||
} catch {
|
||||
warning =
|
||||
"Gespeichert. E-Mail-Versand ist noch ausstehend; bitte im Personalbereich erneut versuchen.";
|
||||
}
|
||||
}
|
||||
revalidatePath("/dashboard/personal");
|
||||
revalidatePath("/dashboard/profil");
|
||||
revalidatePath("/dashboard/erzieher");
|
||||
@@ -25,6 +50,8 @@ export async function staffAction(
|
||||
// Returned only to the authenticated admin who generated this single-use link.
|
||||
return {
|
||||
success: true,
|
||||
warning,
|
||||
message,
|
||||
...(result.invite
|
||||
? { invitePath: `/invite/${result.invite.token}` }
|
||||
: {}),
|
||||
|
||||
@@ -66,6 +66,8 @@ function transform(command: string, form: FormData) {
|
||||
const num = (key: string) => Number(text(key));
|
||||
const hours = (key: string) => Math.round(num(key) * 60);
|
||||
switch (command) {
|
||||
case "emailPreference":
|
||||
return { enabled: form.get("enabled") === "on" };
|
||||
case "setup":
|
||||
return {
|
||||
educatorId: text("educatorId"),
|
||||
@@ -169,7 +171,8 @@ function StaffForm({
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Gespeichert.");
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
onSuccess?.(result);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
@@ -224,7 +227,8 @@ export function CommandButton({
|
||||
const result = await staffAction(command, { ...data, profileId });
|
||||
if (result.error) toast.error(result.error);
|
||||
else {
|
||||
toast.success("Gespeichert.");
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success(result.message ?? "Gespeichert.");
|
||||
router.refresh();
|
||||
}
|
||||
})
|
||||
@@ -535,6 +539,29 @@ function SettingsSection({
|
||||
</details>
|
||||
);
|
||||
}
|
||||
export function StaffEmailSettings({ enabled }: { enabled: boolean }) {
|
||||
return (
|
||||
<StaffForm command="emailPreference" label="E-Mail-Einstellung speichern">
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
defaultChecked={enabled}
|
||||
className="mt-1 accent-[var(--accent-deep)]"
|
||||
/>
|
||||
<span>
|
||||
Entscheidungen zu Urlaub, Freizeitausgleich und Arbeitswochen per
|
||||
E-Mail erhalten.
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Aktivierungslinks und Hinweise innerhalb der App bleiben davon
|
||||
unabhängig.
|
||||
</p>
|
||||
</StaffForm>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
profileId,
|
||||
educatorId,
|
||||
@@ -569,7 +596,7 @@ export function SettingsPanel({
|
||||
<StaffForm
|
||||
command="invite"
|
||||
profileId={profileId}
|
||||
label="Aktivierungslink erstellen"
|
||||
label="Per E-Mail einladen / Account verknüpfen"
|
||||
onSuccess={(r) => setInvitePath(r.invitePath ?? "")}
|
||||
>
|
||||
<Field
|
||||
@@ -580,8 +607,8 @@ export function SettingsPanel({
|
||||
/>
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
Erstellt oder verknüpft einen ErzieherInnen-Account. Der
|
||||
persönliche Link ist sieben Tage gültig. Ein neuer Link ersetzt
|
||||
den vorherigen.
|
||||
persönliche Aktivierungslink wird per E-Mail versendet und ist
|
||||
sieben Tage gültig. Ein neuer Link ersetzt den vorherigen.
|
||||
</p>
|
||||
</StaffForm>
|
||||
)}
|
||||
|
||||
@@ -119,7 +119,9 @@ export default async function PersonalPage({
|
||||
calendarLeaves,
|
||||
pendingLeaves,
|
||||
pendingWeeks,
|
||||
pendingEmails,
|
||||
] = await Promise.all([
|
||||
// All queries stay scoped to the current Kita and recipient permissions.
|
||||
admin
|
||||
? prisma.educator.findMany({
|
||||
where: { kitaId: actor.kitaId },
|
||||
@@ -190,6 +192,14 @@ export default async function PersonalPage({
|
||||
orderBy: { weekStart: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
prisma.staffEmail.count({
|
||||
where: {
|
||||
profile: { kitaId: actor.kitaId },
|
||||
...(admin ? {} : { userId: actor.id }),
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const selectedEducator =
|
||||
educators.find((e) => e.id === pick("educator")) ??
|
||||
@@ -279,6 +289,16 @@ export default async function PersonalPage({
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{pendingEmails > 0 && (
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4">
|
||||
<p className="text-sm">
|
||||
{pendingEmails} E-Mail-Hinweise warten noch auf den Versand.
|
||||
</p>
|
||||
<CommandButton command="emailRetry" profileId="" data={{}}>
|
||||
Versand erneut versuchen
|
||||
</CommandButton>
|
||||
</div>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<details className="mb-6 rounded-xl border border-[var(--accent-mid)] bg-[var(--accent-soft)] p-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold">
|
||||
|
||||
@@ -29,6 +29,7 @@ import { DeleteAccountDialog } from "./_components/delete-account-dialog";
|
||||
import { MyChildrenManager } from "./_components/my-children-manager";
|
||||
import { CreateFamilyDialog } from "./_components/create-family-dialog";
|
||||
import { ContactForm } from "./_components/contact-form";
|
||||
import { StaffEmailSettings } from "../personal/forms";
|
||||
|
||||
export const metadata = { title: "Mein Profil · Kita-Planer" };
|
||||
|
||||
@@ -54,6 +55,7 @@ export default async function ProfilPage() {
|
||||
postalCode: true,
|
||||
city: true,
|
||||
directoryOptInAt: true,
|
||||
staffDecisionEmails: true,
|
||||
createdAt: true,
|
||||
family: {
|
||||
select: {
|
||||
@@ -208,6 +210,19 @@ export default async function ProfilPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{user.role === UserRole.ERZIEHER && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>E-Mail-Hinweise</CardTitle>
|
||||
<CardDescription>
|
||||
Benachrichtigungen aus dem Personalbereich.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StaffEmailSettings enabled={user.staffDecisionEmails} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{user.role !== UserRole.ERZIEHER && (
|
||||
<>
|
||||
<Card>
|
||||
|
||||
@@ -16,12 +16,14 @@ type InviteEmailProps = {
|
||||
parentName: string;
|
||||
kitaName: string;
|
||||
inviteLink: string;
|
||||
staffAccount?: boolean;
|
||||
};
|
||||
|
||||
export function InviteEmail({
|
||||
parentName,
|
||||
kitaName,
|
||||
inviteLink,
|
||||
staffAccount = false,
|
||||
}: InviteEmailProps) {
|
||||
return (
|
||||
<Html lang="de">
|
||||
@@ -36,13 +38,16 @@ export function InviteEmail({
|
||||
<Text style={styles.kicker}>Einladung zur Kita-App</Text>
|
||||
<Heading style={styles.heading}>Willkommen bei {kitaName}</Heading>
|
||||
<Text style={styles.lead}>
|
||||
Hallo {parentName}, dein Eltern-Account wurde vorbereitet.
|
||||
Hallo {parentName}, dein {staffAccount ? "Fachkraft" : "Eltern"}
|
||||
-Account wurde vorbereitet.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.text}>
|
||||
Über den folgenden Link kannst du dein Passwort setzen und deinen
|
||||
Account aktivieren. Danach hast du Zugriff auf die Kita-Planung,
|
||||
Termine und deine Familiendaten.
|
||||
Account aktivieren.{" "}
|
||||
{staffAccount
|
||||
? "Danach kannst du deine Arbeitszeiten erfassen, Urlaub beantragen und dein Zeitkonto einsehen. Der Link ist sieben Tage gültig und kann einmal verwendet werden."
|
||||
: "Danach hast du Zugriff auf die Kita-Planung, Termine und deine Familiendaten."}
|
||||
</Text>
|
||||
|
||||
<Button href={inviteLink} style={styles.button}>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { baseBodyStyle, colors, fontStack } from "./_styles";
|
||||
|
||||
export function StaffNotificationEmail({
|
||||
name,
|
||||
kitaName,
|
||||
message,
|
||||
link,
|
||||
}: {
|
||||
name: string;
|
||||
kitaName: string;
|
||||
message: string;
|
||||
link: string;
|
||||
}) {
|
||||
return (
|
||||
<Html lang="de">
|
||||
<Head />
|
||||
<Preview>Neuer Hinweis im Personalbereich von {kitaName}</Preview>
|
||||
<Body style={baseBodyStyle}>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: "600px",
|
||||
padding: "32px 24px",
|
||||
fontFamily: fontStack,
|
||||
}}
|
||||
>
|
||||
<Heading style={{ color: colors.textPrimary }}>
|
||||
Personalbereich · {kitaName}
|
||||
</Heading>
|
||||
<Text>Hallo {name},</Text>
|
||||
<Text style={{ lineHeight: "24px" }}>{message}</Text>
|
||||
<Text>
|
||||
Die Einzelheiten findest du nach der Anmeldung im Personalbereich.
|
||||
</Text>
|
||||
<Button
|
||||
href={link}
|
||||
style={{
|
||||
backgroundColor: colors.brandGreen,
|
||||
color: "#fff",
|
||||
padding: "14px 20px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
Personalbereich öffnen
|
||||
</Button>
|
||||
<Text style={{ fontSize: "12px", color: colors.textMuted }}>
|
||||
Fachkräfte können Entscheidungshinweise unter „Mein Profil“
|
||||
abschalten. Hinweise innerhalb der App bleiben erhalten.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
+12
-7
@@ -9,6 +9,7 @@ type SendAppEmailInput = {
|
||||
react: ReactNode;
|
||||
from?: string;
|
||||
replyTo?: string | string[];
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
type SendAppEmailResult =
|
||||
@@ -36,6 +37,7 @@ export async function sendAppEmail({
|
||||
react,
|
||||
from = process.env.EMAIL_FROM,
|
||||
replyTo,
|
||||
idempotencyKey,
|
||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
@@ -63,13 +65,16 @@ export async function sendAppEmail({
|
||||
|
||||
try {
|
||||
resendClient ??= new Resend(process.env.RESEND_API_KEY);
|
||||
const { data, error } = await resendClient.emails.send({
|
||||
from: from!,
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
replyTo,
|
||||
});
|
||||
const { data, error } = await resendClient.emails.send(
|
||||
{
|
||||
from: from!,
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
replyTo,
|
||||
},
|
||||
{ idempotencyKey },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error("Resend konnte die E-Mail nicht versenden:", error);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createElement } from "react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendAppEmail } from "@/lib/mail";
|
||||
import { InviteEmail } from "@/emails/InviteEmail";
|
||||
import { StaffNotificationEmail } from "@/emails/StaffNotificationEmail";
|
||||
|
||||
// Called after the staff transaction commits. Tests inject a transport and never send mail.
|
||||
export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
|
||||
let failed = 0;
|
||||
let sent = 0;
|
||||
const deliver = async (id: string) => {
|
||||
const now = new Date();
|
||||
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
|
||||
const claim = await prisma.staffEmail.updateMany({
|
||||
where: {
|
||||
id,
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
|
||||
},
|
||||
data: { leaseUntil, attempts: { increment: 1 } },
|
||||
});
|
||||
if (!claim.count) return;
|
||||
const finish = (data: {
|
||||
sentAt?: Date;
|
||||
skippedAt?: Date;
|
||||
failed: boolean;
|
||||
}) =>
|
||||
prisma.staffEmail.updateMany({
|
||||
where: { id, leaseUntil },
|
||||
data: { ...data, leaseUntil: null },
|
||||
});
|
||||
try {
|
||||
const job = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true,
|
||||
profile: { include: { kita: true, educator: true } },
|
||||
},
|
||||
});
|
||||
const { user, profile } = job;
|
||||
let eligible = user.kitaId === profile.kitaId;
|
||||
if (job.inviteToken) {
|
||||
const token = await prisma.verificationToken.findUnique({
|
||||
where: { token: job.inviteToken },
|
||||
});
|
||||
eligible &&=
|
||||
profile.userId === user.id &&
|
||||
user.role === "ERZIEHER" &&
|
||||
!user.emailVerifiedAt &&
|
||||
!!token &&
|
||||
token.identifier === user.id &&
|
||||
token.expires > now;
|
||||
} else {
|
||||
eligible &&=
|
||||
!!user.privacyPolicyAcceptedAt &&
|
||||
(!user.lockedUntil || user.lockedUntil <= now) &&
|
||||
(job.toAdmins
|
||||
? user.role === "ADMIN"
|
||||
: user.role === "ERZIEHER" &&
|
||||
profile.userId === user.id &&
|
||||
user.staffDecisionEmails);
|
||||
}
|
||||
if (!eligible) {
|
||||
await finish({ skippedAt: now, failed: false });
|
||||
return;
|
||||
}
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.AUTH_URL ||
|
||||
process.env.NEXTAUTH_URL;
|
||||
if (!base) throw new Error("App-Adresse für E-Mails fehlt.");
|
||||
const site = new URL(base);
|
||||
if (
|
||||
!["https:", "http:"].includes(site.protocol) ||
|
||||
site.username ||
|
||||
site.password
|
||||
)
|
||||
throw new Error("App-Adresse für E-Mails ist ungültig.");
|
||||
const link = new URL(job.path, site.origin).toString();
|
||||
const result = await send({
|
||||
to: user.email,
|
||||
subject: job.subject,
|
||||
idempotencyKey: `staff-email/${job.id}`,
|
||||
react: job.inviteToken
|
||||
? createElement(InviteEmail, {
|
||||
parentName: `${profile.educator.firstName} ${profile.educator.lastName}`,
|
||||
kitaName: profile.kita.name,
|
||||
inviteLink: link,
|
||||
staffAccount: true,
|
||||
})
|
||||
: createElement(StaffNotificationEmail, {
|
||||
name: user.firstName,
|
||||
kitaName: profile.kita.name,
|
||||
message: job.message,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
if (!result.success) {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
} else {
|
||||
await finish({ sentAt: new Date(), failed: false });
|
||||
sent++;
|
||||
}
|
||||
} catch {
|
||||
await finish({ failed: true });
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
for (let offset = 0; offset < uniqueIds.length; offset += 4)
|
||||
await Promise.all(uniqueIds.slice(offset, offset + 4).map(deliver));
|
||||
return { sent, failed };
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export type StaffNotice = {
|
||||
toAdmins: boolean;
|
||||
};
|
||||
type CommandResult = {
|
||||
emailIds?: string[];
|
||||
notice?: StaffNotice;
|
||||
invite?: { email: string; name: string; token: string; kitaName: string };
|
||||
};
|
||||
@@ -214,6 +215,33 @@ export async function executeStaffCommand(
|
||||
throw new Error(
|
||||
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
|
||||
);
|
||||
if (command === "emailPreference") {
|
||||
if (actor.role !== UserRole.ERZIEHER)
|
||||
throw new Error(
|
||||
"Diese Einstellung ist für Fachkräfte vorgesehen.",
|
||||
);
|
||||
await tx.user.update({
|
||||
where: { id: actor.id },
|
||||
data: {
|
||||
staffDecisionEmails: z.boolean().parse(data.enabled),
|
||||
},
|
||||
});
|
||||
return {};
|
||||
}
|
||||
if (command === "emailRetry") {
|
||||
const jobs = await tx.staffEmail.findMany({
|
||||
where: {
|
||||
profile: { kitaId: actor.kitaId },
|
||||
...(actor.role === UserRole.ADMIN ? {} : { userId: actor.id }),
|
||||
sentAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 50,
|
||||
});
|
||||
return { emailIds: jobs.map((job) => job.id) };
|
||||
}
|
||||
if (command === "holiday") {
|
||||
requireAdmin(actor);
|
||||
const input = z
|
||||
@@ -356,6 +384,7 @@ export async function executeStaffCommand(
|
||||
const notice = async (
|
||||
text: string,
|
||||
toAdmins: boolean,
|
||||
email?: { message: string; query: string },
|
||||
): Promise<CommandResult> => {
|
||||
const recipients = await tx.user.findMany({
|
||||
where: {
|
||||
@@ -364,7 +393,7 @@ export async function executeStaffCommand(
|
||||
? { role: UserRole.ADMIN }
|
||||
: { id: profile.userId ?? "" }),
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, role: true, staffDecisionEmails: true },
|
||||
});
|
||||
await tx.staffNotification.createMany({
|
||||
data: recipients.map((user) => ({
|
||||
@@ -373,7 +402,28 @@ export async function executeStaffCommand(
|
||||
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${text}`,
|
||||
})),
|
||||
});
|
||||
return { notice: { profileId, text, toAdmins } };
|
||||
const emailIds: string[] = [];
|
||||
if (email) {
|
||||
for (const user of recipients) {
|
||||
if (
|
||||
!toAdmins &&
|
||||
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails)
|
||||
)
|
||||
continue;
|
||||
const job = await tx.staffEmail.create({
|
||||
data: {
|
||||
profileId,
|
||||
userId: user.id,
|
||||
toAdmins,
|
||||
subject: "Kita-Planer: Hinweis im Personalbereich",
|
||||
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${email.message}`,
|
||||
path: `/dashboard/personal?educator=${encodeURIComponent(profile.educatorId)}&${email.query}`,
|
||||
},
|
||||
});
|
||||
emailIds.push(job.id);
|
||||
}
|
||||
}
|
||||
return { notice: { profileId, text, toAdmins }, emailIds };
|
||||
};
|
||||
if (command === "noticeRead") {
|
||||
await tx.staffNotification.updateMany({
|
||||
@@ -446,7 +496,18 @@ export async function executeStaffCommand(
|
||||
where: { id: actor.kitaId },
|
||||
select: { name: true },
|
||||
});
|
||||
const job = await tx.staffEmail.create({
|
||||
data: {
|
||||
profileId,
|
||||
userId: user.id,
|
||||
subject: `Einladung zu ${kita.name} im Kita-Planer`,
|
||||
message: "Account aktivieren",
|
||||
path: `/invite/${token}`,
|
||||
inviteToken: token,
|
||||
},
|
||||
});
|
||||
return {
|
||||
emailIds: [job.id],
|
||||
invite: {
|
||||
email,
|
||||
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
|
||||
@@ -845,6 +906,10 @@ export async function executeStaffCommand(
|
||||
return notice(
|
||||
`Arbeitswoche ab ${start} wurde zur Prüfung eingereicht.`,
|
||||
true,
|
||||
{
|
||||
message: `Arbeitswoche ${start}–${end} wurde zur Prüfung eingereicht.`,
|
||||
query: `tab=zeit&week=${start}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
requireAdmin(actor);
|
||||
@@ -901,6 +966,10 @@ export async function executeStaffCommand(
|
||||
return notice(
|
||||
`Arbeitswoche ab ${start}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}. ${input.reason}`,
|
||||
false,
|
||||
{
|
||||
message: `Arbeitswoche ${start}–${end}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}.`,
|
||||
query: `tab=zeit&week=${start}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -937,6 +1006,12 @@ export async function executeStaffCommand(
|
||||
? "Ein offener Abwesenheitsantrag wurde zurückgezogen."
|
||||
: "Die Stornierung einer genehmigten Abwesenheit wurde beantragt.",
|
||||
true,
|
||||
existing.kind === "SICKNESS"
|
||||
? undefined
|
||||
: {
|
||||
message: `Abwesenheitsantrag ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "zurückgezogen" : "Stornierung beantragt"}.`,
|
||||
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (command === "leaveReview") {
|
||||
@@ -1032,6 +1107,12 @@ export async function executeStaffCommand(
|
||||
return notice(
|
||||
`${existing.kind === "VACATION" ? "Urlaub" : existing.kind === "TIME_OFF" ? "Freizeitausgleich" : "Krankmeldung"}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : "genehmigt"}. ${input.reason}`,
|
||||
false,
|
||||
existing.kind === "SICKNESS"
|
||||
? undefined
|
||||
: {
|
||||
message: `${existing.kind === "VACATION" ? "Urlaub" : "Freizeitausgleich"} ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : existing.status === "CANCEL_REQUESTED" ? "Stornierung abgelehnt; Genehmigung bleibt bestehen" : "genehmigt"}.`,
|
||||
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!profile.educator.active)
|
||||
@@ -1140,6 +1221,12 @@ export async function executeStaffCommand(
|
||||
return notice(
|
||||
`${input.kind === "SICKNESS" ? "Krankheit wurde hinterlegt" : "Ein Abwesenheitsantrag wurde eingereicht"}: ${input.startDate}–${input.endDate}.`,
|
||||
input.kind !== "SICKNESS",
|
||||
input.kind === "SICKNESS"
|
||||
? undefined
|
||||
: {
|
||||
message: `${input.kind === "VACATION" ? "Urlaubsantrag" : "Antrag auf Freizeitausgleich"} ${input.startDate}–${input.endDate} wurde ${existing ? "geändert" : "eingereicht"}.`,
|
||||
query: `tab=urlaub&year=${input.startDate.slice(0, 4)}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw new Error("Unbekannte Aktion.");
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
type StaffActor,
|
||||
} from "../src/lib/staff/service";
|
||||
import { dateOnly, getTimeBalance } from "../src/lib/staff/calculations";
|
||||
import { deliverStaffEmails } from "../src/lib/staff/email";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
process.env.AUTH_URL = "http://127.0.0.1:3008";
|
||||
|
||||
// Never run integration tests against a user's application database.
|
||||
const db = new URL(process.env.DATABASE_URL ?? "postgresql://invalid");
|
||||
@@ -105,6 +109,195 @@ async function fixture() {
|
||||
staffUser,
|
||||
};
|
||||
}
|
||||
test("E-Mail-Aufträge gehen an alle Admins derselben Kita; Versandfehler bleiben wiederholbar", async () => {
|
||||
const f = await fixture(),
|
||||
foreign = await fixture();
|
||||
const second = await prisma.user.create({
|
||||
data: {
|
||||
kitaId: f.kita.id,
|
||||
email: `${randomUUID()}@staff-test.invalid`,
|
||||
passwordHash: "test-only",
|
||||
firstName: "Zweiter",
|
||||
lastName: "Admin",
|
||||
role: "ADMIN",
|
||||
privacyPolicyAcceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await f.run(f.staff, "leave", {
|
||||
...vacation(),
|
||||
note: "VERTRAULICHE_NOTIZ",
|
||||
});
|
||||
const jobs = await prisma.staffEmail.findMany({
|
||||
where: { id: { in: result.emailIds! } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
jobs.map((j) => j.userId).sort(),
|
||||
[f.admin.id, second.id].sort(),
|
||||
);
|
||||
assert.ok(jobs.every((j) => !j.message.includes("VERTRAULICHE_NOTIZ")));
|
||||
assert.equal((await f.run(f.other, "emailRetry")).emailIds?.length, 0);
|
||||
assert.equal(
|
||||
(await foreign.run(foreign.admin, "emailRetry")).emailIds?.length,
|
||||
0,
|
||||
);
|
||||
const failure = await deliverStaffEmails(result.emailIds!, async () => ({
|
||||
success: false,
|
||||
error: "Testausfall",
|
||||
}));
|
||||
assert.equal(failure.failed, 2);
|
||||
assert.equal(
|
||||
await prisma.staffLeave.count({ where: { profileId: f.profile.id } }),
|
||||
1,
|
||||
);
|
||||
const retry = await f.run(f.admin, "emailRetry");
|
||||
const messages: string[] = [];
|
||||
const delivered = await deliverStaffEmails(retry.emailIds!, async (input) => {
|
||||
messages.push(await render(input.react));
|
||||
assert.ok(input.idempotencyKey?.startsWith("staff-email/"));
|
||||
return { success: true, id: "test-only" };
|
||||
});
|
||||
assert.equal(delivered.sent, 2);
|
||||
assert.ok(
|
||||
messages.every(
|
||||
(html) =>
|
||||
html.includes("2026-01-05") && !html.includes("VERTRAULICHE_NOTIZ"),
|
||||
),
|
||||
);
|
||||
assert.equal((await f.run(f.admin, "emailRetry")).emailIds?.length, 0);
|
||||
});
|
||||
|
||||
test("Fachkräfte schalten nur eigene Entscheidungsmails ab; interne Hinweise bleiben erhalten", async () => {
|
||||
const f = await fixture();
|
||||
await f.run(f.staff, "leave", vacation());
|
||||
const leave = await prisma.staffLeave.findFirstOrThrow({
|
||||
where: { profileId: f.profile.id },
|
||||
});
|
||||
const decision = await f.run(f.admin, "leaveReview", {
|
||||
id: leave.id,
|
||||
approve: true,
|
||||
reason: "PRIVATE_BEGRUENDUNG",
|
||||
});
|
||||
assert.equal(decision.emailIds?.length, 1);
|
||||
const queued = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: decision.emailIds![0] },
|
||||
});
|
||||
assert.equal(queued.userId, f.staff.id);
|
||||
assert.ok(!queued.message.includes("PRIVATE_BEGRUENDUNG"));
|
||||
await f.run(f.staff, "emailPreference", { enabled: false });
|
||||
assert.equal(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: f.other.id } }))
|
||||
.staffDecisionEmails,
|
||||
true,
|
||||
);
|
||||
let calls = 0;
|
||||
await deliverStaffEmails(decision.emailIds!, async () => {
|
||||
calls++;
|
||||
return { success: true };
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
await f.run(f.staff, "leave", vacation("2026-01-06"));
|
||||
const next = await prisma.staffLeave.findFirstOrThrow({
|
||||
where: { profileId: f.profile.id, startDate: dateOnly("2026-01-06") },
|
||||
});
|
||||
const disabled = await f.run(f.admin, "leaveReview", {
|
||||
id: next.id,
|
||||
approve: false,
|
||||
reason: "Testentscheidung",
|
||||
});
|
||||
assert.equal(disabled.emailIds?.length, 0);
|
||||
assert.ok(
|
||||
(await prisma.staffNotification.count({ where: { userId: f.staff.id } })) >=
|
||||
2,
|
||||
);
|
||||
const sickness = await f.run(f.admin, "leave", {
|
||||
...vacation("2026-01-08"),
|
||||
kind: "SICKNESS",
|
||||
});
|
||||
assert.equal(sickness.emailIds?.length, 0);
|
||||
await assert.rejects(
|
||||
f.run(f.admin, "emailPreference", { enabled: false }),
|
||||
/Fachkräfte/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Arbeitswochen erzeugen getrennte Einreichungs- und Entscheidungsmails ohne private Rückmeldung", async () => {
|
||||
const f = await fixture();
|
||||
const submitted = await f.run(f.staff, "weekSubmit", {
|
||||
weekStart: "2026-01-05",
|
||||
});
|
||||
const adminJob = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: submitted.emailIds![0] },
|
||||
});
|
||||
assert.equal(adminJob.userId, f.admin.id);
|
||||
assert.ok(adminJob.path.includes("week=2026-01-05"));
|
||||
const reviewed = await f.run(f.admin, "weekReview", {
|
||||
weekStart: "2026-01-05",
|
||||
approve: false,
|
||||
reason: "PRIVATE_RUECKMELDUNG",
|
||||
});
|
||||
const job = await prisma.staffEmail.findUniqueOrThrow({
|
||||
where: { id: reviewed.emailIds![0] },
|
||||
});
|
||||
assert.equal(job.userId, f.staff.id);
|
||||
assert.ok(job.message.includes("zur Korrektur"));
|
||||
assert.ok(!job.message.includes("PRIVATE_RUECKMELDUNG"));
|
||||
});
|
||||
|
||||
test("Erneuerte Aktivierungslinks ersetzen ausstehende alte Mails und funktionieren trotz Opt-out", async () => {
|
||||
const f = await fixture();
|
||||
await prisma.staffProfile.update({
|
||||
where: { id: f.profile.id },
|
||||
data: { userId: null },
|
||||
});
|
||||
const email = `${randomUUID()}@staff-test.invalid`;
|
||||
const old = await f.run(f.admin, "invite", { email });
|
||||
const linked = await prisma.user.findUniqueOrThrow({ where: { email } });
|
||||
await prisma.user.update({
|
||||
where: { id: linked.id },
|
||||
data: { staffDecisionEmails: false },
|
||||
});
|
||||
const renewed = await f.run(f.admin, "invite", { email });
|
||||
const html: string[] = [];
|
||||
const result = await deliverStaffEmails(
|
||||
[...old.emailIds!, ...renewed.emailIds!],
|
||||
async (input) => {
|
||||
assert.equal(input.to, email);
|
||||
html.push(await render(input.react, { plainText: true }));
|
||||
return { success: true };
|
||||
},
|
||||
);
|
||||
assert.equal(result.sent, 1);
|
||||
assert.ok(html[0].includes(renewed.invite!.token));
|
||||
assert.ok(!html[0].includes(old.invite!.token));
|
||||
assert.ok(
|
||||
html[0].includes("Fachkraft-Account") && html[0].includes("sieben Tage"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Parallele Versandversuche senden einmal und beachten inzwischen entzogene Rollen", async () => {
|
||||
const f = await fixture();
|
||||
const result = await f.run(f.staff, "leave", vacation());
|
||||
let calls = 0;
|
||||
const send = async () => {
|
||||
calls++;
|
||||
return { success: true as const };
|
||||
};
|
||||
await Promise.all([
|
||||
deliverStaffEmails(result.emailIds!, send),
|
||||
deliverStaffEmails(result.emailIds!, send),
|
||||
]);
|
||||
assert.equal(calls, 1);
|
||||
await deliverStaffEmails(result.emailIds!, send);
|
||||
assert.equal(calls, 1);
|
||||
const changed = await f.run(f.staff, "leave", vacation("2026-01-06"));
|
||||
await prisma.user.update({
|
||||
where: { id: f.admin.id },
|
||||
data: { role: "ELTERN" },
|
||||
});
|
||||
await deliverStaffEmails(changed.emailIds!, send);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
const vacation = (date = "2026-01-05") => ({
|
||||
kind: "VACATION",
|
||||
startDate: date,
|
||||
|
||||
Reference in New Issue
Block a user