diff --git a/docs/PERSONALBEREICH.md b/docs/PERSONALBEREICH.md index 23d8bdf..1bc9b10 100644 --- a/docs/PERSONALBEREICH.md +++ b/docs/PERSONALBEREICH.md @@ -1,20 +1,26 @@ # Personalbereich: Arbeitszeit, Urlaub und Zeitkonto -Stand: 08.10.2026 · Branch `codex/personalbereich` +Stand: 09.10.2026 ## Einstieg und Zuständigkeiten Der Bereich liegt unter `/dashboard/personal`. Kita-Admins (einschließlich Vorstand mit Rolle `ADMIN`) verwalten alle Personalprofile ihrer Kita. ErzieherInnen bearbeiten eigene Zeiten und Anträge. Eltern und Koordination haben keinen Zugriff. Der interne Teamkalender ist nur für Admins und ErzieherInnen zugänglich. -Bestehende ErzieherInnen-Stammdaten bleiben erhalten. Ein Admin öffnet beim Personalstamm das **Personalprofil** und hinterlegt: +Neue Fachkräfte werden unter **ErzieherInnen → ErzieherIn anlegen** in einem Formular eingerichtet: -1. Beschäftigungsbeginn, optionales Ende, Beginn der Zeiterfassung und Anfangssaldo. -2. Ein datiertes Wochenmodell mit Netto-Sollstunden für Montag bis Sonntag; 0 bedeutet frei. -3. Jahresurlaub und ausdrücklich festgelegten Übertrag, individuell für die Arbeitstage dieser Person. -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. +1. Vorname, Nachname und E-Mail-Adresse (Pflichtfelder). +2. Tatsächlicher Beschäftigungsbeginn und separat der Start der Zeiterfassung (vorbelegt mit heute, änderbar). Die Erfassung darf nicht vor der Beschäftigung beginnen. +3. **Fachkraft anlegen und einladen** speichert Stammdaten, Personalprofil, Accountverknüpfung und Mailauftrag gemeinsam in einer Transaktion. Unpassende oder schon verknüpfte E-Mail-Adressen verhindern die gesamte Anlage. +4. Anschließend öffnet sich das Personalprofil. Dort werden datierte Netto-Sollstunden für Montag bis Sonntag, Jahresurlaub und ausdrücklich festgelegter Übertrag sowie vorhandene Überstunden eingerichtet. Der Anfangssaldo startet mit 0 Minuten und muss bei vorhandenen Überstunden vor der ersten Zeiterfassung angepasst werden. +5. Die örtlichen Feiertage werden unter **Teamkalender** hinterlegt. Sie gelten separat von den Schließtagen des allgemeinen Kalenders. -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. +Vorhandene Fachkräfte können ihre fehlende E-Mail und gegebenenfalls die beiden Startdaten direkt über **Bearbeiten** erhalten. Bereits eingerichtete Beschäftigungsdaten, Wochenmodelle und Konten bleiben dabei erhalten. Die E-Mail eines verknüpften Accounts wird angezeigt und kann im Stammdatendialog nicht geändert werden; Namensänderungen werden auch im verknüpften Fachkraft-Account übernommen. + +Die Teamübersicht zeigt E-Mail und Zugangszustand: **Zugang fehlt**, **Einladung ausstehend**, **Account aktiviert** oder **Mailversand fehlgeschlagen**. Für offene Einladungen steht **Einladung erneut senden** zur Verfügung. Ein neuer Link ersetzt den alten. Ein Mailfehler entfernt keine Fachkraft: Er bleibt sichtbar, der Versand kann erneut angestoßen werden. Im Personalbereich kann außerdem der bestehende Mailauftrag erneut versucht werden. + +Der Aktivierungsablauf verwendet die bestehende Passwort- und Consent-Einrichtung unter `/invite/[token]`. Neue Accounts erhalten einen persönlichen, einmal verwendbaren Aktivierungslink mit sieben Tagen Gültigkeit per E-Mail; im Personalprofil kann der Admin zusätzlich einen Link zum Kopieren erzeugen. Ein vorhandener verifizierter Fachkraft-Account derselben Kita ohne andere Profilverknüpfung wird verbunden, ohne eine Aktivierungsmail zu verschicken. Accounts anderer Kitas oder Rollen werden nicht übernommen. + +Die gemeinsame Anlage benötigt keine zusätzlichen Datenbankfelder oder Umgebungsvariablen. Sie verwendet die bestehende Resend-Anbindung; siehe [Deployment und Mailversand](../deploy/DEPLOYMENT_UND_MAILVERSAND.md). ## Arbeitszeit und Überstunden @@ -72,19 +78,27 @@ Die reale Projekt- und Produktionsdatenbank wurde bei dieser Umsetzung nicht ver Ergebnisse der abschließenden Prüfung: -| 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. | +| Prüfung | Ergebnis | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Berechnungstests | 9 bestanden. | +| Integrationstests | 25 bestanden; einschließlich gemeinsamer Anlage, vollständigem Rollback bei E-Mail-Konflikten, Bearbeitung bestehender Fachkräfte, Kontenschutz, Rollenprüfung und 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. +## Prüfung der gemeinsamen Fachkraft-Anlage (09.10.2026) + +- 25 Personal-Integrationstests, TypeScript, ESLint der geänderten Anwendungsteile und Produktionsbuild erfolgreich. +- Lokale Browserprüfung mit synthetischen Daten: Datum vor Beschäftigungsbeginn abgewiesen, Eingaben erhalten, korrigierte Anlage erfolgreich und unmittelbar zum Personalprofil weitergeleitet. +- Resend im lokalen Produktionsbuild bewusst deaktiviert: gespeicherte Fachkraft und wiederholbarer Mailfehler in Personalbereich und Teamliste sichtbar; erneute Einladung aus der Teamliste erfolgreich ausgelöst. +- Bestehende Fachkraft ohne Zugang zeigt E-Mail und fehlende Startdaten direkt im Bearbeitungsdialog. Mobilprüfung bei 390 × 844 Pixeln: Dialog ohne horizontalen Überlauf, längerer Inhalt innerhalb des Dialogs scrollbar. +- Keine echten Aktivierungsmails versendet; keine Änderung an Produktionsdaten vorgenommen. + ## E-Mail-Hinweise Die E-Mail-Erweiterung wurde am 08.10.2026 ausdrücklich beauftragt. Sie verwendet die vorhandene Resend-Anbindung: diff --git a/src/app/dashboard/erzieher/_components/erzieher-list.tsx b/src/app/dashboard/erzieher/_components/erzieher-list.tsx index 2f50ce7..d1c7c98 100644 --- a/src/app/dashboard/erzieher/_components/erzieher-list.tsx +++ b/src/app/dashboard/erzieher/_components/erzieher-list.tsx @@ -2,6 +2,7 @@ import { useState, useTransition } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { Plus, Pencil, Search, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -30,16 +31,108 @@ import { TableRow, } from "@/components/ui/table"; -import { createEducator, updateEducator, deleteEducator } from "../actions"; +import { + createEducator, + updateEducator, + deleteEducator, + resendEducatorInvitation, +} from "../actions"; type EducatorDto = { id: string; firstName: string; lastName: string; active: boolean; + hasProfile: boolean; + email: string | null; + verified: boolean; + inviteFailed: boolean; }; -export function ErzieherList({ educators }: { educators: EducatorDto[] }) { +function AccessFields({ + prefix, + today, + educator, +}: { + prefix: string; + today: string; + educator?: EducatorDto; +}) { + return ( + <> +
+ + +

+ {educator?.email + ? "Diese Adresse gehört zum verknüpften Account." + : "An diese Adresse senden wir den persönlichen Aktivierungslink."} +

+
+ {!educator?.hasProfile && ( +
+
+ + +
+
+ + +
+

+ Ab diesem Datum werden Arbeitszeiten im Kita-Planer erfasst. + Arbeitstage, Urlaub und vorhandene Überstunden richtest du + anschließend im Personalprofil ein. +

+
+ )} + + ); +} + +export function ErzieherList({ + educators, + today, +}: { + educators: EducatorDto[]; + today: string; +}) { + const router = useRouter(); + const [search, setSearch] = useState(""); + const [formError, setFormError] = useState(""); + const filtered = educators.filter((ed) => + `${ed.firstName} ${ed.lastName} ${ed.email ?? ""}` + .toLocaleLowerCase("de") + .includes(search.toLocaleLowerCase("de")), + ); const [openCreate, setOpenCreate] = useState(false); const [editingEducator, setEditingEducator] = useState( null, @@ -54,15 +147,24 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) { firstName: formData.get("firstName") as string, lastName: formData.get("lastName") as string, active: formData.get("active") === "on", + email: formData.get("email"), + employmentStart: formData.get("employmentStart"), + trackingStart: formData.get("trackingStart"), }; + setFormError(""); startTransition(async () => { const res = await createEducator(data); if (res.error) { - toast.error(res.error); + setFormError(res.error); } else { - toast.success("ErzieherIn angelegt."); + toast.success(res.message ?? "Fachkraft und Personalprofil angelegt."); + if (res.warning) toast.warning(res.warning, { duration: 12000 }); setOpenCreate(false); + if (res.educatorId) + router.push( + `/dashboard/personal?tab=verwaltung&educator=${res.educatorId}`, + ); } }); }; @@ -73,19 +175,35 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) { firstName: formData.get("firstName") as string, lastName: formData.get("lastName") as string, active: formData.get("active") === "on", + email: formData.get("email"), + employmentStart: formData.get("employmentStart"), + trackingStart: formData.get("trackingStart"), }; + setFormError(""); startTransition(async () => { const res = await updateEducator(editingEducator.id, data); if (res.error) { - toast.error(res.error); + setFormError(res.error); } else { - toast.success("Daten aktualisiert."); + toast.success(res.message ?? "Daten aktualisiert."); + if (res.warning) toast.warning(res.warning, { duration: 12000 }); setEditingEducator(null); + router.refresh(); } }); }; + const handleResend = (educator: EducatorDto) => { + startTransition(async () => { + const res = await resendEducatorInvitation(educator.id); + if (res.error) toast.error(res.error); + else if (res.warning) toast.warning(res.warning, { duration: 12000 }); + else toast.success(res.message ?? "Einladung erneuert."); + router.refresh(); + }); + }; + const handleDelete = (educator: EducatorDto) => { startTransition(async () => { const res = await deleteEducator(educator.id); @@ -103,24 +221,44 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
- + setSearch(e.target.value)} + className="pl-9" + />
- + { + if (!isPending) { + setOpenCreate(open); + setFormError(""); + } + }} + > - -
+ + { + event.preventDefault(); + handleCreate(new FormData(event.currentTarget)); + }} + > Neue(n) ErzieherIn anlegen - Erfasse die Stammdaten des Kita-Personals. + Lege Stammdaten, Personalprofil und App-Zugang gemeinsam an. + Die Fachkraft erhält ihren Aktivierungslink per E-Mail. -
+
@@ -129,21 +267,30 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
+
-
+ + {formError && ( +

+ {formError} +

+ )} @@ -169,14 +316,15 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) { Vorname Nachname + E-Mail / App-Zugang Status Aktionen - {educators.length === 0 ? ( + {filtered.length === 0 ? ( - +
@@ -191,12 +339,38 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) { ) : ( - educators.map((ed) => ( + filtered.map((ed) => ( {ed.firstName} {ed.lastName} + +

+ {ed.email ?? "Noch keine E-Mail hinterlegt"} +

+ + {ed.verified + ? "Account aktiviert" + : ed.inviteFailed + ? "Mailversand fehlgeschlagen" + : ed.email + ? "Einladung ausstehend" + : "Zugang fehlt"} + + {ed.email && !ed.verified && ( + + )} +
{ed.active ? "Aktiv" : "Inaktiv"} @@ -218,7 +392,12 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) { @@ -244,15 +423,22 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) { {/* Edit Dialog */} !open && setEditingEducator(null)} + onOpenChange={(open) => { + if (!open && !isPending) setEditingEducator(null); + }} > - -
+ + { + event.preventDefault(); + handleUpdate(new FormData(event.currentTarget)); + }} + > ErzieherIn bearbeiten {editingEducator && ( -
+
+
-
+ + )} + {formError && ( +

+ {formError} +

)} diff --git a/src/app/dashboard/erzieher/actions.ts b/src/app/dashboard/erzieher/actions.ts index defd518..dfaa8ae 100644 --- a/src/app/dashboard/erzieher/actions.ts +++ b/src/app/dashboard/erzieher/actions.ts @@ -6,74 +6,23 @@ import { z } from "zod"; import { requireRole } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; +import { staffAction } from "../personal/actions"; -const educatorSchema = z.object({ - firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(), - lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(), - active: z.boolean().default(true), -}); - +// The staff service creates the person, profile, account and mail job in one transaction. export async function createEducator(rawPayload: unknown) { - const session = await requireRole([UserRole.ADMIN]); - const kitaId = session.user.kitaId; - if (!kitaId) { - return { error: "Kein Mandant zugeordnet." }; - } - - const parsed = educatorSchema.safeParse(rawPayload); - if (!parsed.success) { - return { error: "Ungültige Eingabedaten." }; - } - - try { - await prisma.educator.create({ - data: { - kitaId, - firstName: parsed.data.firstName, - lastName: parsed.data.lastName, - active: parsed.data.active, - }, - }); - - revalidatePath("/dashboard/erzieher"); - return { success: true }; - } catch (error) { - console.error("Fehler beim Anlegen:", error); - return { error: "Ein Fehler ist aufgetreten." }; - } + const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload); + if (!parsed.success) return { error: "Ungültige Eingabedaten." }; + return staffAction("educatorCreate", parsed.data); } export async function updateEducator(id: string, rawPayload: unknown) { - const session = await requireRole([UserRole.ADMIN]); - const kitaId = session.user.kitaId; - if (!kitaId) { - return { error: "Kein Mandant zugeordnet." }; - } + const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload); + if (!parsed.success) return { error: "Ungültige Eingabedaten." }; + return staffAction("educatorUpdate", { ...parsed.data, educatorId: id }); +} - const parsed = educatorSchema.safeParse(rawPayload); - if (!parsed.success) { - return { error: "Ungültige Eingabedaten." }; - } - - try { - await prisma.educator.update({ - where: { - id, - kitaId, - }, - data: { - firstName: parsed.data.firstName, - lastName: parsed.data.lastName, - active: parsed.data.active, - }, - }); - - revalidatePath("/dashboard/erzieher"); - return { success: true }; - } catch (error) { - console.error("Fehler beim Aktualisieren:", error); - return { error: "Ein Fehler ist aufgetreten." }; - } +export async function resendEducatorInvitation(educatorId: string) { + return staffAction("educatorInvite", { educatorId }); } export async function deleteEducator(id: string) { diff --git a/src/app/dashboard/erzieher/page.tsx b/src/app/dashboard/erzieher/page.tsx index 22f41fb..db5991b 100644 --- a/src/app/dashboard/erzieher/page.tsx +++ b/src/app/dashboard/erzieher/page.tsx @@ -3,6 +3,7 @@ import { UserRole } from "@prisma/client"; import { requireRole } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; +import { todayKey } from "@/lib/staff/calculations"; import { ErzieherList } from "./_components/erzieher-list"; export const metadata = { title: "ErzieherInnen · Kita-Planer" }; @@ -17,6 +18,18 @@ export default async function ErzieherPage() { firstName: true, lastName: true, active: true, + staffProfile: { + select: { + id: true, + user: { select: { email: true, emailVerifiedAt: true } }, + emails: { + where: { inviteToken: { not: null }, skippedAt: null }, + orderBy: { createdAt: "desc" }, + take: 1, + select: { failed: true }, + }, + }, + }, }, orderBy: [{ lastName: "asc" }, { firstName: "asc" }], }); @@ -51,7 +64,16 @@ export default async function ErzieherPage() {
- + ({ + ...educator, + hasProfile: !!staffProfile, + email: staffProfile?.user?.email ?? null, + verified: !!staffProfile?.user?.emailVerifiedAt, + inviteFailed: !!staffProfile?.emails[0]?.failed, + }))} + />
); } diff --git a/src/app/dashboard/personal/actions.ts b/src/app/dashboard/personal/actions.ts index c750a31..b12d0a5 100644 --- a/src/app/dashboard/personal/actions.ts +++ b/src/app/dashboard/personal/actions.ts @@ -7,6 +7,7 @@ import { executeStaffCommand } from "@/lib/staff/service"; import { deliverStaffEmails } from "@/lib/staff/email"; export type StaffActionResult = { + educatorId?: string; error?: string; success?: boolean; invitePath?: string; @@ -50,6 +51,7 @@ export async function staffAction( // Returned only to the authenticated admin who generated this single-use link. return { success: true, + educatorId: result.educatorId, warning, message, ...(result.invite diff --git a/src/app/dashboard/personal/forms.tsx b/src/app/dashboard/personal/forms.tsx index 4009ca4..9d9a478 100644 --- a/src/app/dashboard/personal/forms.tsx +++ b/src/app/dashboard/personal/forms.tsx @@ -634,8 +634,8 @@ export function SettingsPanel({ Link kopieren

- Bitte persönlich an die Fachkraft weitergeben. Es wird keine - E-Mail versendet. + Der Link wird per E-Mail versendet. Bei Bedarf kannst du ihn + zusätzlich persönlich an die Fachkraft weitergeben.

)} diff --git a/src/lib/staff/service.ts b/src/lib/staff/service.ts index 1eff8b2..e3355ee 100644 --- a/src/lib/staff/service.ts +++ b/src/lib/staff/service.ts @@ -28,11 +28,123 @@ export type StaffNotice = { toAdmins: boolean; }; type CommandResult = { + educatorId?: string; emailIds?: string[]; notice?: StaffNotice; invite?: { email: string; name: string; token: string; kitaName: string }; }; const idSchema = z.string().min(1).max(100); +const educatorInput = z.object({ + firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100), + lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100), + active: z.boolean().default(true), + email: z + .string() + .trim() + .max(254) + .email("Bitte eine gültige E-Mail-Adresse angeben.") + .toLowerCase(), +}); +const onboardingDates = z + .object({ + employmentStart: dateSchema, + trackingStart: dateSchema, + }) + .refine((input) => input.trackingStart >= input.employmentStart, { + message: + "Der Start der Zeiterfassung darf nicht vor dem Beschäftigungsbeginn liegen.", + }); + +async function inviteStaff( + tx: Prisma.TransactionClient, + actor: StaffActor, + profile: { + id: string; + kitaId: string; + userId: string | null; + user: { email: string; emailVerifiedAt: Date | null } | null; + educator: { firstName: string; lastName: string }; + }, + email: string, +): Promise { + assertStaffAccess(actor, profile, true); + if ( + profile.userId && + (profile.user?.email !== email || profile.user.emailVerifiedAt) + ) + throw new Error( + "Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.", + ); + const existingUser = await tx.user.findUnique({ where: { email } }); + if ( + existingUser && + (existingUser.kitaId !== actor.kitaId || + existingUser.role !== UserRole.ERZIEHER || + (await tx.staffProfile.count({ + where: { userId: existingUser.id, id: { not: profile.id } }, + }))) + ) + throw new Error( + "Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.", + ); + const user = + existingUser ?? + (await tx.user.create({ + data: { + email, + firstName: profile.educator.firstName, + lastName: profile.educator.lastName, + passwordHash: "", + role: UserRole.ERZIEHER, + kitaId: actor.kitaId, + }, + })); + await tx.staffProfile.update({ + where: { id: profile.id }, + data: { userId: user.id }, + }); + await tx.staffHistory.create({ + data: { + profileId: profile.id, + actorId: actor.id, + action: "Account verknüpft", + details: email, + }, + }); + if (user.emailVerifiedAt) return {}; + const token = randomUUID(); + await tx.verificationToken.deleteMany({ where: { identifier: user.id } }); + await tx.verificationToken.create({ + data: { + identifier: user.id, + token, + expires: new Date(Date.now() + 7 * 86400000), + }, + }); + const kita = await tx.kita.findUniqueOrThrow({ + where: { id: actor.kitaId }, + select: { name: true }, + }); + const job = await tx.staffEmail.create({ + data: { + profileId: profile.id, + 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}`, + token, + kitaName: kita.name, + }, + }; +} const reasonSchema = z .string() .trim() @@ -215,6 +327,114 @@ export async function executeStaffCommand( throw new Error( "Dein Konto hat keinen Zugriff auf den Personalbereich.", ); + if ( + ["educatorCreate", "educatorUpdate", "educatorInvite"].includes( + command, + ) + ) { + requireAdmin(actor); + if (command === "educatorInvite") { + const educator = await tx.educator.findFirst({ + where: { + id: idSchema.parse(data.educatorId), + kitaId: actor.kitaId, + }, + include: { + staffProfile: { include: { user: true, educator: true } }, + }, + }); + if (!educator?.staffProfile?.user) + throw new Error( + "Bitte zuerst E-Mail-Adresse und Personalprofil ergänzen.", + ); + return inviteStaff( + tx, + actor, + educator.staffProfile, + educator.staffProfile.user.email, + ); + } + const input = educatorInput.parse(data); + const existing = + command === "educatorUpdate" + ? await tx.educator.findFirst({ + where: { + id: idSchema.parse(data.educatorId), + kitaId: actor.kitaId, + }, + include: { staffProfile: { include: { user: true } } }, + }) + : null; + if (command === "educatorUpdate" && !existing) + throw new Error("Fachkraft nicht gefunden."); + if ( + existing?.staffProfile?.user && + existing.staffProfile.user.email !== input.email + ) + throw new Error( + "Die Adresse eines verknüpften Accounts kann hier nicht geändert werden.", + ); + if (existing?.staffProfile) + assertStaffAccess(actor, existing.staffProfile, true); + // Dates are only supplied when creating the profile. Existing balances and dates stay intact. + const dates = !existing?.staffProfile + ? onboardingDates.parse(data) + : null; + const values = { + firstName: input.firstName, + lastName: input.lastName, + active: input.active, + }; + const educator = existing + ? await tx.educator.update({ + where: { id: existing.id }, + data: values, + }) + : await tx.educator.create({ + data: { ...values, kitaId: actor.kitaId }, + }); + const profile = + existing?.staffProfile ?? + (await tx.staffProfile.create({ + data: { + kitaId: actor.kitaId, + educatorId: educator.id, + employmentStart: dateOnly(dates!.employmentStart), + trackingStart: dateOnly(dates!.trackingStart), + }, + include: { user: true }, + })); + await tx.staffHistory.create({ + data: { + profileId: profile.id, + actorId: actor.id, + action: existing ? "Stammdaten geändert" : "Fachkraft angelegt", + details: `${educator.firstName} ${educator.lastName}${dates ? ` · Beschäftigung ${dates.employmentStart}, Erfassung ${dates.trackingStart}, Anfangssaldo 0 Min.` : ""}`, + }, + }); + if ( + profile.user && + (profile.user.kitaId !== actor.kitaId || + profile.user.role !== UserRole.ERZIEHER) + ) + throw new Error( + "Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.", + ); + if (profile.userId) { + await tx.user.update({ + where: { id: profile.userId }, + data: { firstName: input.firstName, lastName: input.lastName }, + }); + return { educatorId: educator.id }; + } + const invitation = await inviteStaff( + tx, + actor, + { ...profile, educator }, + input.email, + ); + return { ...invitation, educatorId: educator.id }; + } if (command === "emailPreference") { if (actor.role !== UserRole.ERZIEHER) throw new Error( @@ -445,76 +665,7 @@ export async function executeStaffCommand( .email() .toLowerCase() .parse(data.email); - if (profile.userId) { - if (profile.user?.email !== email || profile.user.emailVerifiedAt) - throw new Error( - "Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.", - ); - } - const existingUser = await tx.user.findUnique({ where: { email } }); - if ( - existingUser && - (existingUser.kitaId !== actor.kitaId || - existingUser.role !== UserRole.ERZIEHER || - (await tx.staffProfile.count({ - where: { userId: existingUser.id, id: { not: profileId } }, - }))) - ) - throw new Error( - "Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.", - ); - const user = - existingUser ?? - (await tx.user.create({ - data: { - email, - firstName: profile.educator.firstName, - lastName: profile.educator.lastName, - passwordHash: "", - role: UserRole.ERZIEHER, - kitaId: actor.kitaId, - }, - })); - await tx.staffProfile.update({ - where: { id: profileId }, - data: { userId: user.id }, - }); - await history("Account verknüpft", email); - if (user.emailVerifiedAt) return {}; - const token = randomUUID(); - await tx.verificationToken.deleteMany({ - where: { identifier: user.id }, - }); - await tx.verificationToken.create({ - data: { - identifier: user.id, - token, - expires: new Date(Date.now() + 7 * 86400000), - }, - }); - const kita = await tx.kita.findUniqueOrThrow({ - 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}`, - token, - kitaName: kita.name, - }, - }; + return inviteStaff(tx, actor, profile, email); } if (command === "pattern") { requireAdmin(actor); diff --git a/tests/staff-service.test.ts b/tests/staff-service.test.ts index b507ea8..d4cce7d 100644 --- a/tests/staff-service.test.ts +++ b/tests/staff-service.test.ts @@ -709,3 +709,272 @@ test("Entzogene Rollen, fehlender Consent und gesperrte Accounts können keine P }); await assert.rejects(f.run(f.staff, "time", time()), /keinen Zugriff/); }); + +function onboarding(email = `${randomUUID()}@staff-test.invalid`) { + return { + firstName: " Neue ", + lastName: " Fachkraft ", + active: true, + email, + employmentStart: "2024-08-01", + trackingStart: "2026-10-09", + }; +} + +test("Gemeinsame Anlage speichert Fachkraft, Profil, Account und wiederholbaren Mailauftrag", async () => { + const f = await fixture(); + const input = onboarding(); + const result = await executeStaffCommand(f.admin, { + command: "educatorCreate", + data: input, + }); + const educator = await prisma.educator.findUniqueOrThrow({ + where: { id: result.educatorId }, + include: { staffProfile: { include: { user: true } } }, + }); + assert.equal(educator.firstName, "Neue"); + assert.equal(educator.kitaId, f.kita.id); + assert.equal(educator.staffProfile?.user?.email, input.email); + assert.equal(educator.staffProfile?.user?.emailVerifiedAt, null); + assert.equal(educator.staffProfile?.user?.role, "ERZIEHER"); + assert.equal( + educator.staffProfile?.employmentStart.toISOString().slice(0, 10), + "2024-08-01", + ); + assert.equal( + educator.staffProfile?.trackingStart.toISOString().slice(0, 10), + "2026-10-09", + ); + assert.equal(educator.staffProfile?.openingMinutes, 0); + assert.ok(result.invite); + assert.ok( + await prisma.verificationToken.findUnique({ + where: { token: result.invite.token }, + }), + ); + assert.equal( + ( + await deliverStaffEmails(result.emailIds!, async () => ({ + success: false, + error: "Testausfall", + })) + ).failed, + 1, + ); + assert.ok(await prisma.educator.findUnique({ where: { id: educator.id } })); + const retry = await executeStaffCommand(f.admin, { + command: "emailRetry", + data: {}, + }); + assert.deepEqual(retry.emailIds, result.emailIds); + assert.equal( + ( + await deliverStaffEmails(retry.emailIds!, async () => ({ + success: true, + id: "mock", + })) + ).sent, + 1, + ); +}); + +test("Pflichtfelder und Erfassungsdatum verhindern unvollständige oder falsche Anlagen", async () => { + const f = await fixture(); + const before = await prisma.educator.count({ where: { kitaId: f.kita.id } }); + for (const input of [ + onboarding(""), + { ...onboarding(), firstName: " " }, + { ...onboarding(), trackingStart: "2024-07-31" }, + { ...onboarding(), employmentStart: "2026-02-30" }, + ]) { + await assert.rejects( + executeStaffCommand(f.admin, { command: "educatorCreate", data: input }), + ); + } + assert.equal( + await prisma.educator.count({ where: { kitaId: f.kita.id } }), + before, + ); +}); + +test("Doppelte oder fremde Accounts rollen die gesamte Anlage zurück", async () => { + const f = await fixture(), + foreign = await fixture(); + const parent = await prisma.user.findUniqueOrThrow({ + where: { id: f.parent.id }, + }); + const before = await prisma.educator.count({ where: { kitaId: f.kita.id } }); + for (const email of [ + f.staffUser.email, + foreign.staffUser.email, + parent.email, + ]) { + await assert.rejects( + executeStaffCommand(f.admin, { + command: "educatorCreate", + data: onboarding(email), + }), + /bereits/, + ); + } + assert.equal( + await prisma.educator.count({ where: { kitaId: f.kita.id } }), + before, + ); +}); + +test("Freier aktivierter Fachkraft-Account derselben Kita wird verknüpft", async () => { + const f = await fixture(); + const user = await prisma.user.update({ + where: { id: f.other.id }, + data: { emailVerifiedAt: new Date() }, + }); + const result = await executeStaffCommand(f.admin, { + command: "educatorCreate", + data: onboarding(user.email.toUpperCase()), + }); + const profile = await prisma.staffProfile.findUniqueOrThrow({ + where: { educatorId: result.educatorId }, + }); + assert.equal(profile.userId, user.id); + assert.equal(result.invite, undefined); + assert.equal(result.emailIds, undefined); +}); + +test("Bestehende Fachkraft ohne Profil erhält Zugang im Bearbeitungsablauf", async () => { + const f = await fixture(); + const educator = await prisma.educator.create({ + data: { firstName: "Alt", lastName: "Ohne Profil", kitaId: f.kita.id }, + }); + const input = onboarding(); + await assert.rejects( + executeStaffCommand(f.admin, { + command: "educatorUpdate", + data: { ...input, educatorId: educator.id, email: f.staffUser.email }, + }), + /bereits/, + ); + assert.equal( + (await prisma.educator.findUniqueOrThrow({ where: { id: educator.id } })) + .firstName, + "Alt", + ); + const result = await executeStaffCommand(f.admin, { + command: "educatorUpdate", + data: { ...input, educatorId: educator.id }, + }); + assert.equal(result.educatorId, educator.id); + assert.ok(result.invite); + assert.equal( + await prisma.staffProfile.count({ where: { educatorId: educator.id } }), + 1, + ); +}); + +test("Bearbeiten schützt verknüpfte Identität, Zeiten und Konten", async () => { + const f = await fixture(); + await assert.rejects( + executeStaffCommand(f.admin, { + command: "educatorUpdate", + data: { + ...onboarding(), + educatorId: f.educator.id, + }, + }), + /nicht geändert/, + ); + await executeStaffCommand(f.admin, { + command: "educatorUpdate", + data: { + ...onboarding(f.staffUser.email), + firstName: "Korrigiert", + educatorId: f.educator.id, + employmentStart: "2025-01-01", + trackingStart: "2025-01-01", + }, + }); + const profile = await getStaffProfile(f.admin, f.profile.id); + assert.equal(profile?.openingMinutes, 600); + assert.equal( + profile?.employmentStart.toISOString().slice(0, 10), + "2026-01-01", + ); + assert.equal(profile?.workPatterns.length, 1); + assert.equal(profile?.leaveAccounts.length, 1); + assert.equal(profile?.user?.firstName, "Korrigiert"); +}); + +test("Einladung aus Teamliste erneuert nur eigene ausstehende Aktivierung", async () => { + const f = await fixture(), + foreign = await fixture(); + const result = await executeStaffCommand(f.admin, { + command: "educatorCreate", + data: onboarding(), + }); + for (const actor of [f.staff, f.parent, foreign.admin]) { + await assert.rejects( + executeStaffCommand(actor, { + command: "educatorInvite", + data: { educatorId: result.educatorId }, + }), + ); + } + const renewed = await executeStaffCommand(f.admin, { + command: "educatorInvite", + data: { educatorId: result.educatorId }, + }); + assert.ok(renewed.invite); + assert.notEqual(renewed.invite.token, result.invite?.token); + assert.equal( + await prisma.verificationToken.count({ + where: { token: result.invite?.token }, + }), + 0, + ); + const profile = await prisma.staffProfile.findUniqueOrThrow({ + where: { educatorId: result.educatorId }, + }); + await prisma.user.update({ + where: { id: profile.userId! }, + data: { emailVerifiedAt: new Date() }, + }); + await assert.rejects( + executeStaffCommand(f.admin, { + command: "educatorInvite", + data: { educatorId: result.educatorId }, + }), + /bereits einen Account/, + ); +}); + +test("Nur aktuelle Kita-Admins dürfen Fachkräfte anlegen und bearbeiten", async () => { + const f = await fixture(); + const before = await prisma.educator.count({ where: { kitaId: f.kita.id } }); + for (const actor of [ + f.staff, + f.parent, + { ...f.staff, role: UserRole.ADMIN }, + ]) { + await assert.rejects( + executeStaffCommand(actor, { + command: "educatorCreate", + data: onboarding(), + }), + ); + } + const foreign = await fixture(); + await assert.rejects( + executeStaffCommand(foreign.admin, { + command: "educatorUpdate", + data: { + ...onboarding(f.staffUser.email), + educatorId: f.educator.id, + }, + }), + /nicht gefunden/, + ); + assert.equal( + await prisma.educator.count({ where: { kitaId: f.kita.id } }), + before, + ); +});