From 061024d9012fe7ff7098a032cbb70c351a42f235 Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Mon, 12 Oct 2026 16:28:56 +0200 Subject: [PATCH] feat: show planning availability and manage Kita access roles --- docs/ABLAUFPRUEFUNG.md | 2 +- docs/FUNKTIONSUEBERSICHT.md | 15 + package.json | 1 + src/app/admin/actions.ts | 145 +++-- .../erzieher/_components/erzieher-list.tsx | 15 + src/app/dashboard/erzieher/page.tsx | 20 +- .../_components/families-list-client.tsx | 115 +++- src/app/dashboard/families/actions.ts | 233 ++++--- src/app/dashboard/families/page.tsx | 16 +- src/app/dashboard/notdienst/plan/actions.ts | 192 +++++- src/app/dashboard/notdienst/plan/page.tsx | 87 ++- .../dashboard/notdienst/plan/plan-view.tsx | 187 ++++-- src/app/dashboard/profil/actions.ts | 205 +++--- src/app/dashboard/profil/page.tsx | 3 +- src/app/dashboard/zugriffsrechte/actions.ts | 37 ++ src/app/dashboard/zugriffsrechte/page.tsx | 113 ++++ .../zugriffsrechte/role-change-button.tsx | 166 +++++ .../dashboard/zugriffsrechte/role-list.tsx | 98 +++ src/components/dashboard/sidebar-nav.tsx | 9 + src/components/ui/role-badge.tsx | 12 +- src/lib/alarms/email.ts | 21 +- src/lib/alarms/service.ts | 68 +- src/lib/kita-admin-guard.ts | 41 ++ src/lib/kita-role-management.ts | 116 ++++ src/lib/kita-roles.ts | 19 + src/lib/staff/email.ts | 12 +- src/lib/staff/service.ts | 65 +- tests/kita-roles.integration.test.cjs | 599 ++++++++++++++++++ tests/notdienst-assignment-view.test.cjs | 139 ++++ 29 files changed, 2347 insertions(+), 404 deletions(-) create mode 100644 src/app/dashboard/zugriffsrechte/actions.ts create mode 100644 src/app/dashboard/zugriffsrechte/page.tsx create mode 100644 src/app/dashboard/zugriffsrechte/role-change-button.tsx create mode 100644 src/app/dashboard/zugriffsrechte/role-list.tsx create mode 100644 src/lib/kita-admin-guard.ts create mode 100644 src/lib/kita-role-management.ts create mode 100644 src/lib/kita-roles.ts create mode 100644 tests/kita-roles.integration.test.cjs create mode 100644 tests/notdienst-assignment-view.test.cjs diff --git a/docs/ABLAUFPRUEFUNG.md b/docs/ABLAUFPRUEFUNG.md index e45dddb..42a31df 100644 --- a/docs/ABLAUFPRUEFUNG.md +++ b/docs/ABLAUFPRUEFUNG.md @@ -2,7 +2,7 @@ Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt). -Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand. +Fortschreibung: F01 (Familieneinladungen), F02 (Abwesenheitsverwaltung) und die Entfernung des künstlichen Gruppenfilters aus F12 wurden anschließend auf `codex/family-absence-flows` bearbeitet. Den neuen Ablauf und die Deployment-Schritte beschreibt [Familien und Abwesenheiten](FAMILIEN_UND_ABWESENHEITEN.md). F03, F04 und F05 (Verfügbarkeit bei manueller Zuweisung sowie Erstveröffentlichung) sind inzwischen ebenfalls umgesetzt; den aktuellen Stand und die Prüfungen dokumentiert die Ergänzung vom 12.10.2026 in [Funktionsübersicht](FUNKTIONSUEBERSICHT.md). Die folgenden Befunde dokumentieren weiterhin den ursprünglichen Prüfstand. ## Umfang und Methode diff --git a/docs/FUNKTIONSUEBERSICHT.md b/docs/FUNKTIONSUEBERSICHT.md index 8198642..06c0fc2 100644 --- a/docs/FUNKTIONSUEBERSICHT.md +++ b/docs/FUNKTIONSUEBERSICHT.md @@ -1,5 +1,20 @@ # Kita-Planer – Funktionsübersicht und aktueller Stand +## Ergänzung vom 12.10.2026: Manuelle Planung und Zugriffsrechte + +- Die manuelle Notdienst-Zuweisung gruppiert verfügbare Haushalte zuerst. Familien ohne Verfügbarkeit für den gewählten Tag bleiben als abgestimmter Sonderfall auswählbar und werden nach einer solchen Einteilung ausdrücklich gekennzeichnet. Geschwister zählen als ein Haushalt; Einteilungszahlen gelten ebenfalls je Haushalt. Die Anzeige verwendet dieselben gültigen Monatstage wie die Planung. Bestehende Alarm- und Schließtagssperren bleiben bestehen. +- Kita-Admins verwalten Zugriffsrollen unter `/dashboard/zugriffsrechte` sowie direkt bei Personen in Familien- und Teamverwaltung. Die Übersicht enthält auch Konten ohne eigenen Haushalt, eine Suche und die letzten 15 protokollierten Rollenänderungen der eigenen Kita. +- Haushaltskonten können Eltern, Notdienst-Koordination oder Kita-Admin sein. Ohne Haushalt stehen Koordination und Kita-Admin zur Verfügung. Konten mit Personalprofil können Fachkraft oder Kita-Admin sein; Arbeitszeiten, Anträge und Haushalt bleiben erhalten. Ein Personalprofil wird durch eine Rollenänderung nicht neu angelegt. Systemadmins werden hier nicht verwaltet. Ämter wie „Vorstand“ verleihen keine Zugriffsrechte. +- Rollenänderungen prüfen Akteur und Zielkonto erneut innerhalb einer serialisierbaren Transaktion. Eine zwischenzeitlich geänderte Rolle wird nicht überschrieben. Das bestehende Login liest die Rolle bei jeder Anfrage neu; eigene Herabstufung führt zurück zur Übersicht. Offene Aktivierungslinks benötigen weiterhin die Aktivierung, bevor Zugang entsteht. +- Mindestens ein aktiver Kita-Admin mit verifiziertem Konto, Passwort und Datenschutzzustimmung bleibt erhalten. Herabstufung, eigene Kontolöschung, Support-Löschung/-Sperre und eine E-Mail-Änderung mit erneuter Aktivierung berücksichtigen denselben Schutz. Offene Einladungen, deaktivierte/gesperrte Konten oder fehlende Zustimmung zählen nicht als Ersatz. Gleichzeitige Änderungen werden abgefangen. +- Eltern mit Admin-/Koordinationsrolle erhalten weiterhin Haushaltsalarme, können über ihre persönlichen Links antworten und erhalten Mails nach manueller Umteilung. Fachkräfte mit Admin-Rolle können ihr verknüpftes Personalprofil weiter nutzen; persönliche Benachrichtigungseinstellungen und offene Fachkraftaktivierungen bleiben funktionsfähig. +- Kein Schema-Upgrade erforderlich. + +Prüfbefehl: `TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/kita_roles_test npm run test:roles` nach Anwendung der vorhandenen Migrationen auf dieser separaten lokalen Testdatenbank. Die 27 Rollen-/PostgreSQL-Tests und sechs tatsächlichen Komponenten-Rendertests verwenden synthetische Konten und ersetzte Mailtransporte. + +Abnahme: 33 neue Tests und 193 bestehende Sicherheits-, Planungs-, Kalender-, Versand-, Familien-, Personal- und Alarmtests erfolgreich (226 insgesamt, keine übersprungenen Tests). Browserprüfung auf Desktop und bei 390 Pixeln: verfügbare und bewusst abweichende Einteilung, Rollenänderung aus Familien-/Teamverwaltung, Suche, Änderungshistorie, Schutz für einzigen Admin und eigene Herabstufung mit unmittelbar verweigertem Verwaltungszugriff. Produktionsbuild und TypeScript erfolgreich; ESLint ohne Fehler mit sieben bestehenden Warnungen. + + ## Ergänzung vom 12.10.2026: Notdienst-Benachrichtigungen - Automatische Erinnerungen erfassen auch unvollständige Verfügbarkeiten. Das Soll wird aus allen aktiven Kindern und die Istzahl aus unterschiedlichen gültigen Tagen berechnet. Admin-/Koordinationskonten mit eigenem Haushalt gehören zur Eltern-Empfängergruppe. diff --git a/package.json b/package.json index 566e473..6652b3f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs", "test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs", "test:mail": "node --test tests/mail-transport.test.cjs", + "test:roles": "node --test tests/kita-roles.integration.test.cjs tests/notdienst-assignment-view.test.cjs", "test:runtime": "node --test tests/runtime-supervisor.test.cjs", "test:family-absence": "tsx --test tests/absence-policy.test.ts", "test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts" diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index 93b2db0..d39b7bb 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -7,6 +7,12 @@ import { AuditTargetType, UserRole } from "@prisma/client"; import { z } from "zod"; import { requireRole } from "@/lib/auth-utils"; +import { + assertKitaAdminRemains, + lockKitaAccounts, +} from "@/lib/kita-admin-guard"; +import { serializableTransaction } from "@/lib/transaction"; +import { UserFacingError } from "@/lib/action-error"; import { prisma } from "@/lib/prisma"; const deleteKitaSchema = z.object({ @@ -28,7 +34,8 @@ type ActionResult = { async function requireSuperadminActor() { const session = await requireRole([UserRole.SUPERADMIN]); - if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin"); + if (Date.now() - session.user.authenticatedAt > 15 * 60_000) + redirect("/login?reauth=1&callbackUrl=/admin"); return { id: session.user.id, email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin", @@ -96,7 +103,10 @@ export async function deleteKita( const counts = await getKitaDeleteCounts(kita.id); const attachmentIds = await prisma.$transaction(async (tx) => { - const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } }); + const attachments = await tx.attachment.findMany({ + where: { announcement: { kitaId: kita.id } }, + select: { id: true }, + }); await tx.auditLog.create({ data: { actorUserId: actor.id, @@ -111,7 +121,7 @@ export async function deleteKita( }); await tx.kita.delete({ where: { id: kita.id } }); - return attachments.map(item => item.id); + return attachments.map((item) => item.id); }); await removeAttachmentFiles(attachmentIds); @@ -156,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) { return { error: "Benutzer wurde nicht gefunden." } as const; } if (user.id === actorId) { - return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const; + return { + error: "Das eigene Superadmin-Konto kann hier nicht geändert werden.", + } as const; } if (user.role === UserRole.SUPERADMIN) { - return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const; + return { + error: "Andere Superadmins werden in V1 nicht verwaltet.", + } as const; } if (!user.kitaId) { - return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const; + return { + error: "Nur tenant-gebundene Benutzer können verwaltet werden.", + } as const; } return { user } as const; @@ -188,34 +204,44 @@ export async function deleteUser(rawPayload: unknown): Promise { }; } - const attachmentIds = await prisma.$transaction(async (tx) => { - const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } }); - await tx.verificationToken.deleteMany({ - where: { identifier: user.id }, - }); - await tx.auditLog.create({ - data: { - actorUserId: actor.id, - actorEmail: actor.email, - action: "USER_DELETE", - targetType: AuditTargetType.USER, - targetId: user.id, - targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`, - targetKitaId: user.kitaId, - metadata: { - role: user.role, - kitaName: user.kita?.name ?? null, - kitaSlug: user.kita?.slug ?? null, - familyId: user.familyId, - familyName: user.family?.name ?? null, - familyUserCountBeforeDelete: user.family?._count.users ?? null, + let attachmentIds: string[]; + try { + attachmentIds = await serializableTransaction(async (tx) => { + await lockKitaAccounts(tx, user.kitaId!); + await assertKitaAdminRemains(tx, user.kitaId!, [user.id]); + const attachments = await tx.attachment.findMany({ + where: { announcement: { authorId: user.id } }, + select: { id: true }, + }); + await tx.verificationToken.deleteMany({ + where: { identifier: user.id }, + }); + await tx.auditLog.create({ + data: { + actorUserId: actor.id, + actorEmail: actor.email, + action: "USER_DELETE", + targetType: AuditTargetType.USER, + targetId: user.id, + targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`, + targetKitaId: user.kitaId, + metadata: { + role: user.role, + kitaName: user.kita?.name ?? null, + kitaSlug: user.kita?.slug ?? null, + familyId: user.familyId, + familyName: user.family?.name ?? null, + familyUserCountBeforeDelete: user.family?._count.users ?? null, + }, }, - }, + }); + await tx.user.delete({ where: { id: user.id } }); + return attachments.map((item) => item.id); }); - await tx.user.delete({ where: { id: user.id } }); - return attachments.map(item => item.id); - }); - + } catch (error) { + if (!(error instanceof UserFacingError)) throw error; + return { ok: false, error: error.message }; + } await removeAttachmentFiles(attachmentIds); revalidatePath("/admin"); revalidatePath(`/admin/kitas/${user.kitaId}`); @@ -245,29 +271,41 @@ async function updateUserSupportState( const { user } = lookup; - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: user.id }, - data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) }, - }); - await tx.auditLog.create({ - data: { - actorUserId: actor.id, - actorEmail: actor.email, - action, - targetType: AuditTargetType.USER, - targetId: user.id, - targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`, - targetKitaId: user.kitaId, - metadata: { - role: user.role, - previousLockedUntil: user.lockedUntil?.toISOString() ?? null, - previousFailedLoginAttempts: user.failedLoginAttempts, + try { + await serializableTransaction(async (tx) => { + await lockKitaAccounts(tx, user.kitaId!); + if (action === "USER_LOCK") + await assertKitaAdminRemains(tx, user.kitaId!, [user.id]); + await tx.user.update({ + where: { id: user.id }, + data: { + ...data, + ...(action === "USER_LOCK" + ? { sessionVersion: { increment: 1 } } + : {}), }, - }, + }); + await tx.auditLog.create({ + data: { + actorUserId: actor.id, + actorEmail: actor.email, + action, + targetType: AuditTargetType.USER, + targetId: user.id, + targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`, + targetKitaId: user.kitaId, + metadata: { + role: user.role, + previousLockedUntil: user.lockedUntil?.toISOString() ?? null, + previousFailedLoginAttempts: user.failedLoginAttempts, + }, + }, + }); }); - }); - + } catch (error) { + if (!(error instanceof UserFacingError)) throw error; + return { ok: false, error: error.message }; + } revalidatePath("/admin"); revalidatePath(`/admin/kitas/${user.kitaId}`); revalidatePath("/admin/audit"); @@ -296,4 +334,3 @@ export async function resetFailedLoginAttempts( failedLoginAttempts: 0, }); } - diff --git a/src/app/dashboard/erzieher/_components/erzieher-list.tsx b/src/app/dashboard/erzieher/_components/erzieher-list.tsx index d1c7c98..02c336e 100644 --- a/src/app/dashboard/erzieher/_components/erzieher-list.tsx +++ b/src/app/dashboard/erzieher/_components/erzieher-list.tsx @@ -1,5 +1,9 @@ "use client"; +import { + RoleChangeButton, + type RolePerson, +} from "../../zugriffsrechte/role-change-button"; import { useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -47,6 +51,7 @@ type EducatorDto = { email: string | null; verified: boolean; inviteFailed: boolean; + account: RolePerson | null; }; function AccessFields({ @@ -119,9 +124,11 @@ function AccessFields({ } export function ErzieherList({ + currentUserId, educators, today, }: { + currentUserId: string; educators: EducatorDto[]; today: string; }) { @@ -377,6 +384,14 @@ export function ErzieherList({ + {ed.account ? ( + + + + ) : null} ; + return ( + + ); } diff --git a/src/app/dashboard/families/actions.ts b/src/app/dashboard/families/actions.ts index 301607a..765e78d 100644 --- a/src/app/dashboard/families/actions.ts +++ b/src/app/dashboard/families/actions.ts @@ -11,6 +11,10 @@ import { queueFamilyInvitation, resendFamilyInvitation, } from "@/lib/family-invitations"; +import { + assertKitaAdminRemains, + lockKitaAccounts, +} from "@/lib/kita-admin-guard"; import { prisma } from "@/lib/prisma"; import { assertChildrenRemovable } from "@/lib/notdienst-planning"; import { UserFacingError, publicActionError } from "@/lib/action-error"; @@ -149,7 +153,12 @@ export async function addFamilyAction( return { errors: { _form: ["Kein Mandant zugeordnet."] } }; } - if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } }; + if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) + return { + errors: { + _form: ["Zu viele Einladungen. Bitte versuche es später erneut."], + }, + }; const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData)); if (!parsedFamily.success) { @@ -310,8 +319,11 @@ export async function updateFamilyAction( } const removedChildIdSet = new Set(removedChildIds); - const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id)); - if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." }; + const activeChildren = children.filter( + (child) => !removedChildIdSet.has(child.id), + ); + if (activeChildren.length + newChildren.length > 20) + return { error: "Maximal 20 Kinder pro Familie." }; if (activeChildren.length + newChildren.length === 0) { return { error: "Eine Familie benötigt mindestens ein Kind." }; } @@ -341,13 +353,26 @@ export async function updateFamilyAction( return { error: "Familie wurde nicht gefunden." }; } - const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email); + const needsEmail = + newParents.length > 0 || + parents.some( + (parent) => + existingFamily.users.find((user) => user.id === parent.id)?.email !== + parent.email, + ); if (needsEmail) { - if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." }; + if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) + return { + error: "Zu viele Einladungen. Bitte versuche es später erneut.", + }; } - const existingParentIds = new Set(existingFamily.users.map((user) => user.id)); - const existingChildIds = new Set(existingFamily.children.map((child) => child.id)); + const existingParentIds = new Set( + existingFamily.users.map((user) => user.id), + ); + const existingChildIds = new Set( + existingFamily.children.map((child) => child.id), + ); if (!parents.every((parent) => existingParentIds.has(parent.id))) { return { @@ -367,79 +392,109 @@ export async function updateFamilyAction( return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." }; } - await prisma.$transaction(async (tx) => { - await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds); - const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } }); - if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie."); - await tx.family.update({ - where: { id: familyId }, - data: { name: familyName }, - }); - - for (const parent of parents) { - const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } }); - if (!current) throw new Error("Parent assignment changed"); - const emailChanged = current.email !== parent.email; - await tx.user.update({ - where: { id: parent.id, kitaId, familyId }, - data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email, - ...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) }, + await prisma.$transaction( + async (tx) => { + await lockKitaAccounts(tx, kitaId); + const changedEmails = parents + .filter( + (parent) => + existingFamily.users.find((user) => user.id === parent.id) + ?.email !== parent.email, + ) + .map((parent) => parent.id); + await assertKitaAdminRemains(tx, kitaId, changedEmails); + await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds); + const removed = await tx.child.count({ + where: { kitaId, familyId, id: { in: removedChildIds } }, }); - if (emailChanged) { + if (removed !== removedChildIds.length) + throw new UserFacingError( + "Mindestens ein Kind gehört nicht zu dieser Familie.", + ); + await tx.family.update({ + where: { id: familyId }, + data: { name: familyName }, + }); + + for (const parent of parents) { + const current = await tx.user.findFirst({ + where: { id: parent.id, kitaId, familyId }, + select: { email: true }, + }); + if (!current) throw new Error("Parent assignment changed"); + const emailChanged = current.email !== parent.email; + await tx.user.update({ + where: { id: parent.id, kitaId, familyId }, + data: { + firstName: parent.firstName, + lastName: parent.lastName, + email: parent.email, + ...(emailChanged + ? { + passwordHash: "", + emailVerifiedAt: null, + sessionVersion: { increment: 1 }, + } + : {}), + }, + }); + if (emailChanged) { + const invite = await queueFamilyInvitation( + tx, + parent.id, + session.user.id, + ); + inviteJobs.push(invite.id); + } + } + + for (const parent of newParents) { + const createdParent = await tx.user.create({ + data: { + kitaId, + familyId, + email: parent.email, + firstName: parent.firstName, + lastName: parent.lastName, + passwordHash: "", + role: UserRole.ELTERN, + }, + }); const invite = await queueFamilyInvitation( tx, - parent.id, + createdParent.id, session.user.id, ); inviteJobs.push(invite.id); } - } - for (const parent of newParents) { - const createdParent = await tx.user.create({ - data: { - kitaId, - familyId, - email: parent.email, - firstName: parent.firstName, - lastName: parent.lastName, - passwordHash: "", - role: UserRole.ELTERN, - }, - }); - const invite = await queueFamilyInvitation( - tx, - createdParent.id, - session.user.id, - ); - inviteJobs.push(invite.id); - } + for (const child of activeChildren) { + await tx.child.update({ + where: { id: child.id, kitaId, familyId }, + data: { + firstName: child.firstName, + lastName: child.lastName, + }, + }); + } - for (const child of activeChildren) { - await tx.child.update({ - where: { id: child.id, kitaId, familyId }, - data: { - firstName: child.firstName, - lastName: child.lastName, - }, - }); - } + for (const childId of removedChildIds) { + await tx.child.delete({ where: { id: childId, kitaId, familyId } }); + } - for (const childId of removedChildIds) { - await tx.child.delete({ where: { id: childId, kitaId, familyId } }); - } - - for (const child of newChildren) { - await tx.child.create({ - data: { - kitaId, - familyId, - firstName: child.firstName, - lastName: child.lastName, - }, - }); - } - }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + for (const child of newChildren) { + await tx.child.create({ + data: { + kitaId, + familyId, + firstName: child.firstName, + lastName: child.lastName, + }, + }); + } + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && @@ -448,8 +503,14 @@ export async function updateFamilyAction( return { error: "Mindestens eine E-Mail-Adresse existiert bereits." }; } - if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error); - return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") }; + if (!(error instanceof UserFacingError)) + console.error("Fehler beim Aktualisieren der Familie:", error); + return { + error: publicActionError( + error, + "Familie konnte nicht aktualisiert werden.", + ), + }; } const warning = await sendInviteJobs(inviteJobs); @@ -464,15 +525,33 @@ export async function resendFamilyInvitationAction( userId: string, ): Promise<{ success?: boolean; error?: string; warning?: string }> { const session = await requireRole([UserRole.ADMIN]); - if (!session.user.kitaId || typeof userId !== "string" || !userId || userId.length > 64) + if ( + !session.user.kitaId || + typeof userId !== "string" || + !userId || + userId.length > 64 + ) return { error: "Ungültiges Elternkonto." }; - if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) + if ( + !(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)) + .allowed + ) return { error: "Bitte versuche es später erneut." }; - if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) + if ( + !( + await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600) + ).allowed + ) return { error: "Bitte versuche es später erneut." }; const recipient = await prisma.user.findFirst({ - where: { id: userId, kitaId: session.user.kitaId, familyId: { not: null }, - emailVerifiedAt: null, passwordHash: "", disabledAt: null }, + where: { + id: userId, + kitaId: session.user.kitaId, + familyId: { not: null }, + emailVerifiedAt: null, + passwordHash: "", + disabledAt: null, + }, select: { email: true }, }); if (!recipient) return { error: "Keine offene Einladung gefunden." }; diff --git a/src/app/dashboard/families/page.tsx b/src/app/dashboard/families/page.tsx index 6266370..9c14c6c 100644 --- a/src/app/dashboard/families/page.tsx +++ b/src/app/dashboard/families/page.tsx @@ -32,9 +32,17 @@ export default async function FamiliesPage() { firstName: true, lastName: true, email: true, + role: true, + staffProfile: { select: { id: true } }, emailVerifiedAt: true, familyInvitation: { - select: { expiresAt: true, sentAt: true, failed: true, leaseUntil: true, status: true }, + select: { + expiresAt: true, + sentAt: true, + failed: true, + leaseUntil: true, + status: true, + }, }, dutyAssignments: { select: { @@ -77,11 +85,15 @@ export default async function FamiliesPage() { return (
({ ...family, users: family.users.map(({ familyInvitation, ...parent }) => ({ ...parent, - invitation: familyInvitationView(parent.emailVerifiedAt, familyInvitation), + invitation: familyInvitationView( + parent.emailVerifiedAt, + familyInvitation, + ), })), }))} allDuties={allDuties} diff --git a/src/app/dashboard/notdienst/plan/actions.ts b/src/app/dashboard/notdienst/plan/actions.ts index 1303df5..fc5aa24 100644 --- a/src/app/dashboard/notdienst/plan/actions.ts +++ b/src/app/dashboard/notdienst/plan/actions.ts @@ -15,7 +15,11 @@ import { getTargetMonthData } from "@/lib/date-utils"; import { loadNotdienstDays } from "@/lib/notdienst-planning"; import { todayKey } from "@/lib/alarms/format"; import { serializableTransaction } from "@/lib/transaction"; -import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications"; +import { + queueNotdienstPublication, + queueNotdienstReminders, + deliverDueNotdienstEmails, +} from "@/lib/notdienst-notifications"; import { sendAppEmail } from "@/lib/mail"; import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail"; import { requireAlarmActor } from "@/lib/alarms/auth"; @@ -43,14 +47,20 @@ export async function generatePlanAction(year?: number, month?: number) { targetMonth = data.targetMonth; } - if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." }; + if (!validPlanMonth(targetYear, targetMonth)) + return { error: "Ungültiger Planungsmonat." }; try { await serializableTransaction(async (tx) => { const existingPlan = await tx.notdienstPlan.findUnique({ - where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } }, + where: { + kitaId_year_month: { kitaId, year: targetYear, month: targetMonth }, + }, }); - if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits."); + if (existingPlan) + throw new UserFacingError( + "Ein Plan für diesen Monat existiert bereits.", + ); const [availabilities, workingDays] = await Promise.all([ tx.notdienstAvailability.findMany({ @@ -69,16 +79,29 @@ export async function generatePlanAction(year?: number, month?: number) { const familyUsageCount = new Map(); const assignments: { childId: string; date: Date }[] = []; for (const day of workingDays) { - const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime()); + const candidates = availabilities.filter( + (a) => a.date.getTime() === day.getTime(), + ); if (!candidates.length) continue; - const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0)); - const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage); - const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)]; + const minUsage = Math.min( + ...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0), + ); + const leastUsed = candidates.filter( + (a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage, + ); + const selected = + leastUsed[Math.floor(Math.random() * leastUsed.length)]; assignments.push({ childId: selected.childId, date: day }); familyUsageCount.set(selected.child.familyId, minUsage + 1); } const plan = await tx.notdienstPlan.create({ - data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id }, + data: { + kitaId, + year: targetYear, + month: targetMonth, + status: NotdienstPlanStatus.DRAFT, + createdById: session.user.id, + }, }); if (assignments.length) { await tx.notdienstAssignment.createMany({ @@ -91,7 +114,9 @@ export async function generatePlanAction(year?: number, month?: number) { return { success: true }; } catch (error) { console.error(error); - return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") }; + return { + error: publicActionError(error, "Fehler bei der Generierung des Plans."), + }; } } @@ -103,30 +128,55 @@ export async function publishPlanAction(planId: string) { await serializableTransaction(async (tx) => { const plan = await tx.notdienstPlan.findUnique({ where: { id: planId, kitaId }, - include: { assignments: { include: { child: { select: { active: true } } } } }, + include: { + assignments: { include: { child: { select: { active: true } } } }, + }, }); if (!plan) throw new UserFacingError("Plan nicht gefunden."); if (plan.status === NotdienstPlanStatus.PUBLISHED) return; - if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden."); + if (plan.status !== NotdienstPlanStatus.DRAFT) + throw new UserFacingError("Nur Entwürfe können veröffentlicht werden."); const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month); const validDates = new Set(days.map((day) => day.getTime())); - if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) { - throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren."); + if ( + plan.assignments.some( + (a) => !validDates.has(a.date.getTime()) || !a.child.active, + ) + ) { + throw new UserFacingError( + "Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.", + ); } await tx.notdienstPlan.update({ where: { id: planId, kitaId }, - data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() }, + data: { + status: NotdienstPlanStatus.PUBLISHED, + publishedAt: new Date(), + }, }); - await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month); + await queueNotdienstPublication( + tx, + kitaId, + planId, + plan.year, + plan.month, + ); }); revalidatePath("/dashboard/notdienst/plan"); revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard/kalender"); - const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true })); + const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch( + () => ({ sent: 0, failed: 0, skipped: 0, deferred: true }), + ); revalidatePath("/dashboard/notdienst/plan"); return { success: true, ...delivery }; } catch (error) { - return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") }; + return { + error: publicActionError( + error, + "Der Plan konnte nicht veröffentlicht werden.", + ), + }; } } @@ -138,7 +188,8 @@ export async function updateAssignmentAction( const actor = await requireAlarmActor(); const kitaId = actor.kitaId; - if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." }; + if (!dateOnlySchema.safeParse(date).success) + return { error: "Ungültiges Datum." }; const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number); const parsedDate = new Date(Date.UTC(yr, mo - 1, dy)); @@ -161,14 +212,27 @@ export async function updateAssignmentAction( throw new UserFacingError("Plan kann nicht bearbeitet werden."); } - if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) { - throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat."); + if ( + newChildId && + (parsedDate.getUTCFullYear() !== plan.year || + parsedDate.getUTCMonth() + 1 !== plan.month) + ) { + throw new UserFacingError( + "Das Datum gehört nicht zum Planungsmonat.", + ); } // Clearing obsolete entries remains possible after a new closure. if (newChildId) { - const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month); + const days = await loadNotdienstDays( + tx, + kitaId, + plan.year, + plan.month, + ); if (!days.some((day) => day.getTime() === parsedDate.getTime())) { - throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden."); + throw new UserFacingError( + "An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.", + ); } } @@ -189,7 +253,18 @@ export async function updateAssignmentAction( family: { include: { users: { - where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, + where: { + role: { + in: [ + UserRole.ELTERN, + UserRole.ADMIN, + UserRole.KOORDINATOR, + ], + }, + emailVerifiedAt: { not: null }, + disabledAt: null, + privacyPolicyAcceptedAt: { not: null }, + }, select: { email: true, firstName: true, lastName: true }, }, }, @@ -217,7 +292,18 @@ export async function updateAssignmentAction( family: { include: { users: { - where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, + where: { + role: { + in: [ + UserRole.ELTERN, + UserRole.ADMIN, + UserRole.KOORDINATOR, + ], + }, + emailVerifiedAt: { not: null }, + disabledAt: null, + privacyPolicyAcceptedAt: { not: null }, + }, select: { email: true, firstName: true, lastName: true }, }, }, @@ -392,7 +478,15 @@ export async function updateKitaNotdienstSettingsAction( return { error: "Kein Mandant zugeordnet." }; } - if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." }; + if ( + !Number.isInteger(minPerChild) || + minPerChild < 1 || + minPerChild > 31 || + !Number.isInteger(reminderDays) || + reminderDays < 0 || + reminderDays > 31 + ) + return { error: "Ungültige Einstellungen." }; try { await prisma.kita.update({ @@ -411,33 +505,61 @@ export async function updateKitaNotdienstSettingsAction( } } -export async function sendNotdienstRemindersAction(year: number, month: number) { +export async function sendNotdienstRemindersAction( + year: number, + month: number, +) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId!; - if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." }; - if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." }; + if (!validPlanMonth(year, month)) + return { error: "Ungültiger Planungsmonat." }; + if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) + return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." }; try { const queued = await queueNotdienstReminders(kitaId, year, month); - const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true })); + const delivery = await deliverDueNotdienstEmails({ + kitaId, + year, + month, + }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true })); revalidatePath("/dashboard/notdienst/plan"); return { success: true, queued, count: delivery.sent, ...delivery }; } catch (error) { - return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") }; + return { + error: publicActionError( + error, + "Die Erinnerungen konnten nicht vorbereitet werden.", + ), + }; } } export async function retryNotdienstEmailsAction(year: number, month: number) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId!; - if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." }; - if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." }; + if (!validPlanMonth(year, month)) + return { error: "Ungültiger Planungsmonat." }; + if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) + return { error: "Bitte warte vor einem weiteren Versandversuch." }; try { - await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } }); + await prisma.notdienstEmail.updateMany({ + where: { + kitaId, + year, + month, + failed: true, + sentAt: null, + skippedAt: null, + }, + data: { nextAttemptAt: new Date() }, + }); const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }); revalidatePath("/dashboard/notdienst/plan"); return { success: true, ...delivery }; } catch { - return { error: "Der Versand bleibt gespeichert und wird erneut versucht." }; + return { + error: "Der Versand bleibt gespeichert und wird erneut versucht.", + }; } } diff --git a/src/app/dashboard/notdienst/plan/page.tsx b/src/app/dashboard/notdienst/plan/page.tsx index 743d354..c71ed15 100644 --- a/src/app/dashboard/notdienst/plan/page.tsx +++ b/src/app/dashboard/notdienst/plan/page.tsx @@ -4,7 +4,11 @@ import { de } from "date-fns/locale"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { prisma } from "@/lib/prisma"; -import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils"; +import { + formatUtcDateKey, + getWorkingDaysOfMonth, + getTargetMonthData, +} from "@/lib/date-utils"; import { loadNotdienstDays } from "@/lib/notdienst-planning"; import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications"; import { EmailStatusCard } from "./_components/email-status-card"; @@ -108,8 +112,6 @@ export default async function PlanungsZentralePage({ ]); // Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown. - // Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle", - // aber für MVP reichen alle aktiven Kinder. const allChildrenRaw = await prisma.child.findMany({ where: { kitaId, active: true }, select: { @@ -146,8 +148,11 @@ export default async function PlanungsZentralePage({ }); const validDateKeys = new Set(workingDays.map(formatUtcDateKey)); - const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date))); - const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length; + const availabilities = rawAvailabilities.filter((a) => + validDateKeys.has(formatUtcDateKey(a.date)), + ); + const closureCount = + getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length; const availabilityDatesByFamily = new Map>(); availabilities.forEach((availability) => { @@ -186,37 +191,52 @@ export default async function PlanungsZentralePage({ // Wir mappen die Assignments auf die Werktage // Keep obsolete assignments visible so the coordinator can remove them. - const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day])); + const displayedDays = new Map( + workingDays.map((day) => [formatUtcDateKey(day), day]), + ); for (const assignment of plan?.assignments ?? []) { displayedDays.set(formatUtcDateKey(assignment.date), assignment.date); } const activeChildIds = new Set(allChildren.map((child) => child.id)); - const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => { - const assignment = plan?.assignments.find( - (a) => a.date.getTime() === day.getTime(), - ); - const isOriginallyUnassigned = - !assignment || - (plan?.publishedAt && assignment.createdAt > plan.publishedAt); + const daysWithAssignments = Array.from(displayedDays.values()) + .sort((a, b) => a.getTime() - b.getTime()) + .map((day) => { + const assignment = plan?.assignments.find( + (a) => a.date.getTime() === day.getTime(), + ); + const isOriginallyUnassigned = + !assignment || + (plan?.publishedAt && assignment.createdAt > plan.publishedAt); - const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId); - return { - date: day.toISOString(), - isInvalid, - assignmentId: assignment?.id || null, - childId: assignment?.childId || null, - isOriginallyUnassigned: !!isOriginallyUnassigned, - activeAlarmId: assignment?.alarms[0]?.id ?? null, - canAlarm: - !!assignment && !isInvalid && - plan?.status === "PUBLISHED" && - day.toISOString().slice(0, 10) >= todayKey(), - }; - }); + const isInvalid = + !validDateKeys.has(formatUtcDateKey(day)) || + (!!assignment && !activeChildIds.has(assignment.childId)); + return { + date: day.toISOString(), + isInvalid, + assignmentId: assignment?.id || null, + childId: assignment?.childId || null, + isOriginallyUnassigned: !!isOriginallyUnassigned, + activeAlarmId: assignment?.alarms[0]?.id ?? null, + canAlarm: + !!assignment && + !isInvalid && + plan?.status === "PUBLISHED" && + day.toISOString().slice(0, 10) >= todayKey(), + }; + }); - const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth); + const mailStatus = await getNotdienstEmailStatus( + kitaId, + targetYear, + targetMonth, + ); const target = getTargetMonthData(now); - const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED"; + const remindersOpen = + !target.isLocked && + target.targetYear === targetYear && + target.targetMonth === targetMonth && + plan?.status !== "PUBLISHED"; return (
@@ -261,7 +281,14 @@ export default async function PlanungsZentralePage({
- = 32} /> + = 32} + /> { - const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet); + const result = await updateAssignmentAction( + planId, + date.slice(0, 10), + valueToSet, + ); if ("error" in result && result.error) { toast.error(result.error as string); } else { @@ -248,7 +264,10 @@ export function PlanView({ {family.childCount === 1 ? "" : "er"}

- +
{family.selectedDays.length > 0 ? family.selectedDays.map((day) => day.label).join(" · ") @@ -318,8 +337,8 @@ export function PlanView({ Plan automatisch generieren

- Verteilt {requiredDays} Notdiensttage fair auf{" "} - die verfügbaren Familien. + Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren + Familien.

@@ -412,6 +431,7 @@ export function PlanView({ @@ -461,7 +487,13 @@ function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number } return ( - = requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}> + = requiredDays + ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" + : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]" + } + > {selectedDays} / {requiredDays} Tage ); @@ -552,7 +584,11 @@ function PlanPreviewTable({ {format(dateObj, "EEEE", { locale: de })}
- {assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"} + {assignedChild + ? assignedChild.parentName + : day.childId + ? "Inaktives Kind" + : "Unbesetzt"} {assignedChild && ( ({assignedChild.name}) @@ -576,8 +612,20 @@ function PlanPreviewTable({
Noch offen
- - {day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"} + + {day.isInvalid + ? "Bitte korrigieren" + : assignedChild + ? "Besetzt" + : "Offen"}
@@ -588,17 +636,29 @@ function PlanPreviewTable({ ); } -function ManualAssignmentTable({ +export function ManualAssignmentTable({ days, allChildren, + familyStatuses, isDraft, isPending, onAssignmentChange, -}: Pick & { +}: Pick & { isDraft: boolean; isPending: boolean; onAssignmentChange: (date: string, newChildId: string) => void; }) { + const availabilityByFamily = useMemo( + () => + new Map( + familyStatuses.map((family) => [ + family.id, + new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))), + ]), + ), + [familyStatuses], + ); + // Map childId to familyId for quick lookup const childFamilyMap = useMemo(() => { const map = new Map(); @@ -626,7 +686,12 @@ function ManualAssignmentTable({ return ( -
+
+ Verfügbare Familien stehen zuerst. Familien ohne Verfügbarkeit für den + Tag bleiben für abgestimmte Sonderfälle auswählbar. Die Einteilungszahl + gilt für den gesamten Haushalt. +
+
Datum
Eingeteilte Familie
@@ -634,42 +699,82 @@ function ManualAssignmentTable({ {days.map((day) => { const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number); const dateObj = new Date(yr, mo - 1, dy); + const dateKey = day.date.slice(0, 10); + const available = allChildren.filter((child) => + availabilityByFamily.get(child.familyId)?.has(dateKey), + ); + const unavailable = allChildren.filter( + (child) => !availabilityByFamily.get(child.familyId)?.has(dateKey), + ); + const availableFamilies = new Set( + available.map((child) => child.familyId), + ).size; + const selectedChild = allChildren.find( + (child) => child.id === day.childId, + ); + const selectedAvailable = + !!selectedChild && + availabilityByFamily.get(selectedChild.familyId)?.has(dateKey); + const option = (child: PlanViewProps["allChildren"][number]) => ( + + ); return (
{format(dateObj, "EE, dd.MM.", { locale: de })}
- + onAssignmentChange(day.date, event.target.value) + } + > + + {day.childId && + !allChildren.some((child) => child.id === day.childId) ? ( + - ); - })} - + ) : null} + {available.length > 0 ? ( + + {available.map(option)} + + ) : null} + {unavailable.length > 0 ? ( + + {unavailable.map(option)} + + ) : null} + + {!day.isInvalid ? ( +

+ {selectedChild + ? selectedAvailable + ? "Eingeteilte Familie ist verfügbar." + : "Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben." + : `${availableFamilies} ${availableFamilies === 1 ? "Familie ist" : "Familien sind"} verfügbar.`} +

+ ) : null} +
{day.isInvalid ? ( - Einteilung nicht mehr gültig – bitte entfernen. + + Einteilung nicht mehr gültig – bitte entfernen. + ) : null} {!isDraft && ( diff --git a/src/app/dashboard/profil/actions.ts b/src/app/dashboard/profil/actions.ts index 71ccc74..f1b47fc 100644 --- a/src/app/dashboard/profil/actions.ts +++ b/src/app/dashboard/profil/actions.ts @@ -11,6 +11,10 @@ import { parseDateInput, } from "@/lib/child-birthdate"; import { serializableTransaction } from "@/lib/transaction"; +import { + assertKitaAdminRemains, + lockKitaAccounts, +} from "@/lib/kita-admin-guard"; import { prisma } from "@/lib/prisma"; import { assertChildrenRemovable } from "@/lib/notdienst-planning"; import { UserFacingError, publicActionError } from "@/lib/action-error"; @@ -59,7 +63,11 @@ const childSchema = z }); const familySchema = z.object({ - familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(), + familyName: z + .string() + .min(1, "Familienname ist erforderlich.") + .max(120) + .trim(), }); const contactSchema = z.object({ @@ -88,10 +96,7 @@ const contactSchema = z.object({ // Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück, // damit der User nicht nur "Ungültige Eingabedaten" sieht. -function firstZodMessage( - error: z.ZodError, - fallback: string, -): string { +function firstZodMessage(error: z.ZodError, fallback: string): string { const issue = error.issues[0]; return issue?.message || fallback; } @@ -121,8 +126,13 @@ export async function createMyChild(rawPayload: unknown) { } try { - await serializableTransaction(async tx => { - const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } }); + await serializableTransaction(async (tx) => { + const count = await tx.child.count({ + where: { + kitaId: session.user.kitaId, + familyId: session.user.familyId!, + }, + }); if (count >= 20) throw new Error("Household child limit reached"); await tx.child.create({ data: { @@ -166,10 +176,17 @@ export async function createMyFamily(rawPayload: unknown) { }); const attached = await tx.user.updateMany({ - where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion }, + where: { + id: session.user.id, + kitaId: session.user.kitaId, + familyId: null, + disabledAt: null, + sessionVersion: session.user.sessionVersion, + }, data: { familyId: family.id }, }); - if (attached.count !== 1) throw new Error("Account already assigned or revoked"); + if (attached.count !== 1) + throw new Error("Account already assigned or revoked"); }); revalidatePath("/dashboard/profil"); @@ -178,7 +195,12 @@ export async function createMyFamily(rawPayload: unknown) { revalidatePath("/dashboard"); return { success: true }; } catch (error) { - if (!(error instanceof Error && error.message === "Account already assigned or revoked")) { + if ( + !( + error instanceof Error && + error.message === "Account already assigned or revoked" + ) + ) { console.error("Fehler beim Anlegen des eigenen Haushalts:", error); } return { error: "Der Haushalt konnte nicht angelegt werden." }; @@ -249,7 +271,11 @@ export async function updateMyChild(childId: string, rawPayload: unknown) { } await prisma.child.update({ - where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! }, + where: { + id: childId, + kitaId: session.user.kitaId, + familyId: session.user.familyId!, + }, data: { firstName: parsed.data.firstName, lastName: parsed.data.lastName, @@ -271,15 +297,34 @@ export async function deleteMyChild(childId: string) { try { await serializableTransaction(async (tx) => { - if (!session.user.familyId) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt."); + if (!session.user.familyId) + throw new UserFacingError( + "Dieses Kind gehört nicht zu deinem Haushalt.", + ); const child = await tx.child.findFirst({ - where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId }, + where: { + id: childId, + kitaId: session.user.kitaId, + familyId: session.user.familyId, + }, select: { id: true }, }); - if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt."); - await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]); + if (!child) + throw new UserFacingError( + "Dieses Kind gehört nicht zu deinem Haushalt.", + ); + await assertChildrenRemovable( + tx, + session.user.kitaId, + session.user.familyId, + [childId], + ); await tx.child.delete({ - where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId }, + where: { + id: childId, + kitaId: session.user.kitaId, + familyId: session.user.familyId, + }, }); }); @@ -287,89 +332,67 @@ export async function deleteMyChild(childId: string) { revalidatePath("/dashboard/families"); return { success: true }; } catch (error) { - if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error); - return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") }; + if (!(error instanceof UserFacingError)) + console.error("Fehler beim Entfernen des Kindes:", error); + return { + error: publicActionError(error, "Das Kind konnte nicht entfernt werden."), + }; } } -export async function deleteMyAccount() { +export async function deleteMyAccount(): Promise<{ + success?: boolean; + error?: string; +}> { const session = await requireKitaSession(); - try { - const user = await prisma.user.findFirst({ - where: { - id: session.user.id, - kitaId: session.user.kitaId, - }, - select: { - id: true, - familyId: true, - }, - }); - - if (!user) { - return { error: "Account wurde nicht gefunden." }; - } - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const activeNotdienstAssignments = user.familyId - ? await prisma.notdienstAssignment.count({ - where: { - kitaId: session.user.kitaId, - date: { gte: today }, - plan: { status: NotdienstPlanStatus.PUBLISHED }, - child: { familyId: user.familyId }, - }, - }) - : 0; - - if (activeNotdienstAssignments > 0) { - return { - error: + return await serializableTransaction(async (tx) => { + await lockKitaAccounts(tx, session.user.kitaId); + const user = await tx.user.findFirst({ + where: { id: session.user.id, kitaId: session.user.kitaId }, + select: { id: true, familyId: true }, + }); + if (!user) throw new UserFacingError("Account wurde nicht gefunden."); + await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const assignments = user.familyId + ? await tx.notdienstAssignment.count({ + where: { + kitaId: session.user.kitaId, + date: { gte: today }, + plan: { status: NotdienstPlanStatus.PUBLISHED }, + child: { familyId: user.familyId }, + }, + }) + : 0; + if (assignments) + throw new UserFacingError( "Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.", - }; - } - - if (!user.familyId) { - await prisma.user.delete({ - where: { id: user.id }, - }); + ); + if (user.familyId) { + const family = await tx.family.findFirst({ + where: { id: user.familyId, kitaId: session.user.kitaId }, + select: { id: true, users: { select: { id: true } } }, + }); + if (!family) + throw new UserFacingError("Haushalt wurde nicht gefunden."); + if (family.users.length <= 1) { + await tx.family.delete({ where: { id: family.id } }); + return { success: true }; + } + } + await tx.user.delete({ where: { id: user.id } }); return { success: true }; - } - - const family = await prisma.family.findFirst({ - where: { - id: user.familyId, - kitaId: session.user.kitaId, - }, - select: { - id: true, - users: { - select: { id: true }, - }, - }, }); - - if (!family) { - return { error: "Haushalt wurde nicht gefunden." }; - } - - if (family.users.length <= 1) { - await prisma.family.delete({ - where: { id: family.id }, - }); - return { success: true }; - } - - await prisma.user.delete({ - where: { id: user.id }, - }); - - return { success: true }; } catch (error) { - console.error("Fehler beim Löschen des Accounts:", error); - return { error: "Ein Fehler ist beim Löschen aufgetreten." }; + if (!(error instanceof UserFacingError)) + console.error("Fehler beim Löschen des Accounts:", error); + return { + error: publicActionError( + error, + "Ein Fehler ist beim Löschen aufgetreten.", + ), + }; } } diff --git a/src/app/dashboard/profil/page.tsx b/src/app/dashboard/profil/page.tsx index 738cca1..29ac742 100644 --- a/src/app/dashboard/profil/page.tsx +++ b/src/app/dashboard/profil/page.tsx @@ -56,6 +56,7 @@ export default async function ProfilPage() { city: true, directoryOptInAt: true, staffDecisionEmails: true, + staffProfile: { select: { id: true } }, createdAt: true, family: { select: { @@ -210,7 +211,7 @@ export default async function ProfilPage() { - {user.role === UserRole.ERZIEHER && ( + {user.staffProfile && ( E-Mail-Hinweise diff --git a/src/app/dashboard/zugriffsrechte/actions.ts b/src/app/dashboard/zugriffsrechte/actions.ts new file mode 100644 index 0000000..cbb4b94 --- /dev/null +++ b/src/app/dashboard/zugriffsrechte/actions.ts @@ -0,0 +1,37 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { UserRole } from "@prisma/client"; +import { requireRole } from "@/lib/auth-utils"; +import { changeKitaRole } from "@/lib/kita-role-management"; +import { publicActionError, UserFacingError } from "@/lib/action-error"; + +export async function changeKitaRoleAction( + raw: unknown, +): Promise<{ + success?: boolean; + error?: string; + changed?: boolean; + ownRoleChanged?: boolean; +}> { + const { user } = await requireRole([UserRole.ADMIN]); + if (!user.kitaId) return { error: "Keine Kita zugeordnet." }; + try { + const result = await changeKitaRole( + { id: user.id, kitaId: user.kitaId, sessionVersion: user.sessionVersion }, + raw, + ); + revalidatePath("/dashboard", "layout"); + revalidatePath("/admin"); + return { success: true, ...result }; + } catch (error) { + if (!(error instanceof UserFacingError)) + console.error("Rolle konnte nicht geändert werden:", error); + return { + error: publicActionError( + error, + "Rolle konnte nicht geändert werden. Bitte erneut versuchen.", + ), + }; + } +} diff --git a/src/app/dashboard/zugriffsrechte/page.tsx b/src/app/dashboard/zugriffsrechte/page.tsx new file mode 100644 index 0000000..29467b5 --- /dev/null +++ b/src/app/dashboard/zugriffsrechte/page.tsx @@ -0,0 +1,113 @@ +import { UserRole } from "@prisma/client"; +import { requireRole } from "@/lib/auth-utils"; +import { prisma } from "@/lib/prisma"; +import { getKitaRoleHistory } from "@/lib/kita-role-management"; +import { kitaRoleLabels } from "@/lib/kita-roles"; +import { Card } from "@/components/ui/card"; +import { RoleList } from "./role-list"; + +export const metadata = { title: "Zugriffsrechte · Kita-Planer" }; +export default async function KitaRolesPage() { + const { user } = await requireRole([UserRole.ADMIN]); + const kitaId = user.kitaId!; + const [people, history] = await Promise.all([ + prisma.user.findMany({ + where: { kitaId, role: { not: UserRole.SUPERADMIN } }, + orderBy: [{ lastName: "asc" }, { firstName: "asc" }], + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + familyId: true, + disabledAt: true, + lockedUntil: true, + emailVerifiedAt: true, + family: { select: { name: true } }, + staffProfile: { select: { id: true } }, + }, + }), + getKitaRoleHistory(kitaId), + ]); + const now = new Date(); + return ( +
+
+

+ VERWALTUNG · KITA-KONTEN +

+

+ Zugriffsrechte{" "} + verwalten +

+

+ Ernenne weitere Kita-Admins oder die Notdienst-Koordination. Rollen + gelten sofort; offene Einladungen müssen weiterhin aktiviert werden. + Mindestens ein aktiver Kita-Admin bleibt erhalten. +

+
+ ({ + id: person.id, + name: `${person.firstName} ${person.lastName}`, + email: person.email, + role: person.role, + familyId: person.familyId, + familyName: person.family?.name ?? null, + hasStaffProfile: !!person.staffProfile, + status: person.disabledAt + ? "Konto deaktiviert" + : person.lockedUntil && person.lockedUntil > now + ? "Anmeldung gesperrt" + : !person.emailVerifiedAt + ? "Einladung offen" + : "Zugang aktiviert", + }))} + /> + +

+ Letzte Rollenänderungen +

+

+ Ämter und Zuständigkeiten verwaltest du weiterhin bei den Familien. + Sie verändern die Zugriffsrechte nicht. +

+ {history.length ? ( +
    + {history.map((entry) => { + const data = entry.metadata as { + previousRole?: UserRole; + newRole?: UserRole; + } | null; + return ( +
  • +

    + {entry.targetLabel}:{" "} + {data?.previousRole + ? kitaRoleLabels[data.previousRole] + : "—"}{" "} + → {data?.newRole ? kitaRoleLabels[data.newRole] : "—"} +

    +

    + {new Intl.DateTimeFormat("de-DE", { + dateStyle: "short", + timeStyle: "short", + timeZone: "Europe/Berlin", + }).format(entry.createdAt)}{" "} + · {entry.actorEmail} +

    +
  • + ); + })} +
+ ) : ( +

+ Noch keine Rollenänderungen protokolliert. +

+ )} +
+
+ ); +} diff --git a/src/app/dashboard/zugriffsrechte/role-change-button.tsx b/src/app/dashboard/zugriffsrechte/role-change-button.tsx new file mode 100644 index 0000000..687d0d9 --- /dev/null +++ b/src/app/dashboard/zugriffsrechte/role-change-button.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import type { UserRole } from "@prisma/client"; +import { ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { availableKitaRoles, kitaRoleLabels } from "@/lib/kita-roles"; +import { changeKitaRoleAction } from "./actions"; + +const descriptions: Record = { + ELTERN: "Eigener Haushalt, Verfügbarkeiten, Diensttausch und Kita-Alltag.", + KOORDINATOR: "Zusätzlich Notdienstpläne, Alarme und Termine koordinieren.", + ADMIN: + "Familien, Team, Zugriffsrechte und alle Kita-Verwaltungsbereiche verwalten.", + ERZIEHER: + "Personalbereich, eigene Arbeitszeit und Anträge sowie Kinder-Abwesenheiten.", +}; + +export type RolePerson = { + id: string; + name: string; + role: UserRole; + familyId: string | null; + hasStaffProfile: boolean; +}; + +export function RoleChangeButton({ + person, + ownAccount = false, +}: { + person: RolePerson; + ownAccount?: boolean; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [role, setRole] = useState(person.role); + const [error, setError] = useState(""); + const [pending, startTransition] = useTransition(); + const roles = availableKitaRoles(person); + const submit = () => { + setError(""); + startTransition(async () => { + try { + const result = await changeKitaRoleAction({ + userId: person.id, + role, + expectedRole: person.role, + }); + if (result.error) { + setError(result.error); + return; + } + toast.success("Zugriffsrolle gespeichert."); + setOpen(false); + if (result.ownRoleChanged) router.replace("/dashboard"); + router.refresh(); + } catch { + setError( + "Die Änderung konnte nicht gespeichert werden. Bitte erneut versuchen.", + ); + } + }); + }; + return ( + <> + + { + if (!pending) setOpen(next); + }} + > + + + Zugriffsrechte für {person.name} + + Die Zugriffsrolle bestimmt, welche Bereiche diese Person nutzen + und verwalten kann. Ämter wie „Vorstand“ werden separat vergeben. + + +
+ + Zugriffsrolle + + {roles.map((value) => ( + + ))} +
+

+ {person.hasStaffProfile + ? "Das Personalprofil mit Arbeitszeiten und Anträgen bleibt erhalten. Mit Kita-Admin-Rechten ist zusätzlich die Personalverwaltung zugänglich." + : person.familyId + ? "Haushalt, Kinder und eigene Dienste bleiben erhalten." + : "Dieses Konto hat keinen eigenen Haushalt. Deshalb steht die Elternrolle nicht zur Auswahl."} +

+ {ownAccount && role !== person.role ? ( +

+ Du änderst deine eigenen Rechte. Nach dem Speichern gelangst du + zur Übersicht; der Zugang zur Verwaltung richtet sich dann nach + der neuen Rolle. +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+ + ); +} diff --git a/src/app/dashboard/zugriffsrechte/role-list.tsx b/src/app/dashboard/zugriffsrechte/role-list.tsx new file mode 100644 index 0000000..ca2eb01 --- /dev/null +++ b/src/app/dashboard/zugriffsrechte/role-list.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Card } from "@/components/ui/card"; +import { RoleBadge } from "@/components/ui/role-badge"; +import { kitaRoleLabels } from "@/lib/kita-roles"; +import { RoleChangeButton, type RolePerson } from "./role-change-button"; + +type Person = RolePerson & { + email: string; + familyName: string | null; + status: string; +}; +export function RoleList({ + people, + currentUserId, +}: { + people: Person[]; + currentUserId: string; +}) { + const [query, setQuery] = useState(""); + const search = query.trim().toLocaleLowerCase("de"); + const filtered = people.filter((person) => + `${person.name} ${person.email} ${person.familyName ?? ""} ${kitaRoleLabels[person.role]}` + .toLocaleLowerCase("de") + .includes(search), + ); + return ( +
+
+ setQuery(event.target.value)} + className="max-w-md" + /> + + {filtered.length} {filtered.length === 1 ? "Konto" : "Konten"} + +
+ +
+ Person & Zuordnung + Zugriffsrolle + Verwalten +
+
+ {filtered.map((person) => ( +
+
+

+ {person.name} + {person.id === currentUserId ? ( + + Du + + ) : null} +

+

+ {person.email} +

+

+ {[ + person.familyName + ? `Haushalt ${person.familyName}` + : "Kein eigener Haushalt", + person.hasStaffProfile ? "Personalprofil vorhanden" : null, + person.status, + ] + .filter(Boolean) + .join(" · ")} +

+
+ +
+ +
+
+ ))} + {!filtered.length ? ( +

+ Keine passenden Kita-Konten gefunden. +

+ ) : null} +
+
+
+ ); +} diff --git a/src/components/dashboard/sidebar-nav.tsx b/src/components/dashboard/sidebar-nav.tsx index c21a72b..0db0b16 100644 --- a/src/components/dashboard/sidebar-nav.tsx +++ b/src/components/dashboard/sidebar-nav.tsx @@ -14,6 +14,7 @@ import { Megaphone, Clock3, BellRing, + ShieldCheck, } from "lucide-react"; import { NavItem } from "@/components/ui/nav-item"; @@ -21,6 +22,14 @@ import { NavItem } from "@/components/ui/nav-item"; import { UserRole } from "@prisma/client"; const navItems = [ + { + section: "Verwaltung", + href: "/dashboard/zugriffsrechte", + label: "Zugriffsrechte", + icon: ShieldCheck, + exact: true, + allowedRoles: [UserRole.ADMIN], + }, { section: "Heute", href: "/dashboard/notdienst/alarm", diff --git a/src/components/ui/role-badge.tsx b/src/components/ui/role-badge.tsx index 6ea6642..dfa42a6 100644 --- a/src/components/ui/role-badge.tsx +++ b/src/components/ui/role-badge.tsx @@ -1,5 +1,7 @@ import type { UserRole } from "@prisma/client"; +import { kitaRoleLabels } from "@/lib/kita-roles"; + import { cn } from "@/lib/utils"; type RoleBadgeProps = { @@ -7,14 +9,6 @@ type RoleBadgeProps = { className?: string; }; -const roleLabels: Record = { - SUPERADMIN: "Admin", - ADMIN: "Vorstand", - KOORDINATOR: "Koordinator", - ELTERN: "Eltern", - ERZIEHER: "Erzieher", -}; - export function RoleBadge({ role, className }: RoleBadgeProps) { return ( - {roleLabels[role] ?? role} + {kitaRoleLabels[role]} ); } diff --git a/src/lib/alarms/email.ts b/src/lib/alarms/email.ts index 98d0f51..071c337 100644 --- a/src/lib/alarms/email.ts +++ b/src/lib/alarms/email.ts @@ -39,14 +39,29 @@ export async function deliverAlarmEmails( include: { user: true, alarm: { include: { kita: true } } }, }); const { user, alarm } = job; - let eligible = user.kitaId === alarm.kitaId && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now); + let eligible = + user.kitaId === alarm.kitaId && + !user.disabledAt && + !!user.emailVerifiedAt && + !!user.privacyPolicyAcceptedAt && + (!user.lockedUntil || user.lockedUntil <= now); if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role); else - eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId; + eligible &&= + ["ELTERN", "ADMIN", "KOORDINATOR"].includes(user.role) && + user.familyId === alarm.familyId; if (job.kind === "REQUEST") { const token = job.path.split("/").pop(); const recipient = await prisma.notdienstAlarmRecipient.findFirst({ - where: { alarmId: alarm.id, userId: user.id, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ token }] : [])] }, + where: { + alarmId: alarm.id, + userId: user.id, + sessionVersion: user.sessionVersion, + OR: [ + { token: hashToken(token ?? "") }, + ...(token?.length === 36 ? [{ token }] : []), + ], + }, }); eligible &&= !!recipient && alarm.status === "PENDING" && alarm.expiresAt > now; diff --git a/src/lib/alarms/service.ts b/src/lib/alarms/service.ts index b4b259d..3c6cb4a 100644 --- a/src/lib/alarms/service.ts +++ b/src/lib/alarms/service.ts @@ -114,22 +114,30 @@ async function queue( const users = audience === "managers" ? await tx.user.findMany({ - where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, + where: { + kitaId: alarm.kitaId, + role: { in: managerRoles }, + emailVerifiedAt: { not: null }, + disabledAt: null, + privacyPolicyAcceptedAt: { not: null }, + }, }) : await tx.user.findMany({ where: { kitaId: alarm.kitaId, familyId: alarm.familyId, - role: "ELTERN", - emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null }, + role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] }, + emailVerifiedAt: { not: null }, + disabledAt: null, + privacyPolicyAcceptedAt: { not: null }, alarmRecipients: { some: { alarmId: alarm.id } }, }, }); const jobs: string[] = []; for (const user of users) { - const recipient = - kind === "REQUEST" ? requestTokens?.get(user.id) : null; - if (kind === "REQUEST" && !recipient) throw new Error("Request token missing"); + const recipient = kind === "REQUEST" ? requestTokens?.get(user.id) : null; + if (kind === "REQUEST" && !recipient) + throw new Error("Request token missing"); await tx.notdienstAlarmNotification.create({ data: { alarmId: alarm.id, userId: user.id, eventKey, message }, }); @@ -204,7 +212,15 @@ export async function executeAlarmCommand( include: { family: { include: { - users: { where: { role: "ELTERN", kitaId: actor.kitaId, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } }, + users: { + where: { + role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] }, + kitaId: actor.kitaId, + emailVerifiedAt: { not: null }, + disabledAt: null, + privacyPolicyAcceptedAt: { not: null }, + }, + }, }, }, }, @@ -221,10 +237,14 @@ export async function executeAlarmCommand( if (!assignment.kita.notdienstModuleEnabled) throw new UserFacingError("Notdienst ist für diese Kita deaktiviert."); if (assignment.plan.status !== "PUBLISHED") - throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen."); + throw new UserFacingError( + "Bitte den Notdienstplan zuerst veröffentlichen.", + ); const date = dateKey(assignment.date); if (date < todayKey(now)) - throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden."); + throw new UserFacingError( + "Vergangene Notdienste können nicht alarmiert werden.", + ); const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute); if (expiresAt <= now) throw new UserFacingError("Dieser Einsatz ist bereits beendet."); @@ -245,7 +265,9 @@ export async function executeAlarmCommand( "Für diese Einteilung besteht bereits ein aktiver Alarm.", ); const parents = assignment.child.family.users; - const requestTokens = new Map(parents.map(parent => [parent.id, newToken()])); + const requestTokens = new Map( + parents.map((parent) => [parent.id, newToken()]), + ); if (!parents.length) throw new UserFacingError( "Für diesen Haushalt sind keine Elternkonten hinterlegt.", @@ -353,7 +375,9 @@ export async function executeAlarmCommand( ); const reason = note.parse(data.reason); if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION") - throw new UserFacingError("Es liegt keine verspätete Zusage zur Freigabe vor."); + throw new UserFacingError( + "Es liegt keine verspätete Zusage zur Freigabe vor.", + ); const approve = command === "approveLate" || z.boolean().parse(data.approve); const updated = await tx.notdienstAlarm.update({ @@ -401,7 +425,12 @@ export async function executeAlarmCommand( async function recipientForToken(tx: Tx, token: string) { const recipient = await tx.notdienstAlarmRecipient.findFirst({ - where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] }, + where: { + OR: [ + { token: hashToken(token) }, + ...(token.length === 36 ? [{ token }] : []), + ], + }, include: { user: true, alarm: { include: { kita: { select: { name: true } } } }, @@ -409,9 +438,11 @@ async function recipientForToken(tx: Tx, token: string) { }); if ( !recipient || - recipient.user.role !== "ELTERN" || + !["ELTERN", "ADMIN", "KOORDINATOR"].includes(recipient.user.role) || recipient.sessionVersion !== recipient.user.sessionVersion || - !!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt || + !!recipient.user.disabledAt || + !recipient.user.emailVerifiedAt || + !recipient.user.privacyPolicyAcceptedAt || recipient.alarm.expiresAt <= new Date() || (recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) || recipient.user.kitaId !== recipient.alarm.kitaId || @@ -423,7 +454,12 @@ async function recipientForToken(tx: Tx, token: string) { export async function getPublicAlarm(token: string) { if (!tokenSchema.safeParse(token).success) return null; const legacy = await prisma.notdienstAlert.findFirst({ - where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ confirmationToken: token }] : [])] }, + where: { + OR: [ + { confirmationToken: hashToken(token) }, + ...(token.length === 36 ? [{ confirmationToken: token }] : []), + ], + }, select: { alarmId: true, assignmentId: true }, }); if (legacy && !legacy.alarmId) @@ -591,7 +627,7 @@ export async function importLegacyAlarms( const old = assignment.alerts; const valid = old.filter( (a) => - a.parentUser.role === "ELTERN" && + ["ELTERN", "ADMIN", "KOORDINATOR"].includes(a.parentUser.role) && a.parentUser.familyId === assignment.child.familyId && a.parentUser.kitaId === assignment.kitaId, ); diff --git a/src/lib/kita-admin-guard.ts b/src/lib/kita-admin-guard.ts new file mode 100644 index 0000000..6eb1c89 --- /dev/null +++ b/src/lib/kita-admin-guard.ts @@ -0,0 +1,41 @@ +import type { Prisma } from "@prisma/client"; +import { UserFacingError } from "@/lib/action-error"; + +// Serialize role changes, account deletion and support locks for the same Kita. +export async function lockKitaAccounts( + tx: Prisma.TransactionClient, + kitaId: string, +) { + const rows = await tx.$queryRaw< + { id: string }[] + >`SELECT id FROM kitas WHERE id = ${kitaId} FOR UPDATE`; + if (!rows.length) throw new UserFacingError("Kita wurde nicht gefunden."); +} + +export async function assertKitaAdminRemains( + tx: Prisma.TransactionClient, + kitaId: string, + excludedUserIds: string[], +) { + const removesAdmin = await tx.user.count({ + where: { kitaId, id: { in: excludedUserIds }, role: "ADMIN" }, + }); + if (!removesAdmin) return; + const now = new Date(); + const remaining = await tx.user.count({ + where: { + kitaId, + id: { notIn: excludedUserIds }, + role: "ADMIN", + disabledAt: null, + emailVerifiedAt: { not: null }, + privacyPolicyAcceptedAt: { not: null }, + passwordHash: { not: "" }, + OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }], + }, + }); + if (!remaining) + throw new UserFacingError( + "Mindestens ein aktiver Kita-Admin muss erhalten bleiben. Ernenne zuerst eine weitere Person mit aktiviertem Zugang und Datenschutzzustimmung.", + ); +} diff --git a/src/lib/kita-role-management.ts b/src/lib/kita-role-management.ts new file mode 100644 index 0000000..e333168 --- /dev/null +++ b/src/lib/kita-role-management.ts @@ -0,0 +1,116 @@ +import "server-only"; +import { AuditTargetType } from "@prisma/client"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import { serializableTransaction } from "@/lib/transaction"; +import { UserFacingError } from "@/lib/action-error"; +import { + assertKitaAdminRemains, + lockKitaAccounts, +} from "@/lib/kita-admin-guard"; +import { availableKitaRoles } from "@/lib/kita-roles"; + +const inputSchema = z.object({ + userId: z.string().min(1).max(64), + role: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]), + expectedRole: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]), +}); + +type Actor = { id: string; kitaId: string; sessionVersion: number }; +export async function changeKitaRole(actor: Actor, raw: unknown) { + const input = inputSchema.safeParse(raw); + if (!input.success) throw new UserFacingError("Ungültige Rollenänderung."); + return serializableTransaction(async (tx) => { + await lockKitaAccounts(tx, actor.kitaId); + const currentActor = await tx.user.findFirst({ + where: { + id: actor.id, + kitaId: actor.kitaId, + role: "ADMIN", + sessionVersion: actor.sessionVersion, + disabledAt: null, + emailVerifiedAt: { not: null }, + privacyPolicyAcceptedAt: { not: null }, + OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }], + }, + select: { id: true, email: true }, + }); + if (!currentActor) + throw new UserFacingError( + "Du darfst die Zugriffsrechte nicht mehr ändern. Bitte lade die Seite neu.", + ); + const person = await tx.user.findFirst({ + where: { id: input.data.userId, kitaId: actor.kitaId }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + familyId: true, + staffProfile: { select: { id: true, kitaId: true } }, + }, + }); + if (!person || person.role === "SUPERADMIN") + throw new UserFacingError( + "Dieses Kita-Konto kann hier nicht geändert werden.", + ); + if (person.role !== input.data.expectedRole) + throw new UserFacingError( + "Die Rolle wurde inzwischen geändert. Bitte lade die Seite neu.", + ); + if (person.staffProfile && person.staffProfile.kitaId !== actor.kitaId) + throw new UserFacingError( + "Das Personalprofil gehört nicht zu dieser Kita.", + ); + if ( + !availableKitaRoles({ + familyId: person.familyId, + hasStaffProfile: !!person.staffProfile, + }).includes(input.data.role) + ) + throw new UserFacingError( + person.staffProfile + ? "Ein Personalprofil benötigt die Rolle Fachkraft oder Kita-Admin." + : "Eltern benötigen einen Haushalt; die Fachkraftrolle benötigt ein Personalprofil.", + ); + if (person.role === input.data.role) + return { changed: false, ownRoleChanged: false }; + if (person.role === "ADMIN") + await assertKitaAdminRemains(tx, actor.kitaId, [person.id]); + await tx.user.update({ + where: { id: person.id, kitaId: actor.kitaId }, + data: { role: input.data.role }, + }); + await tx.auditLog.create({ + data: { + actorUserId: currentActor.id, + actorEmail: currentActor.email, + action: "USER_ROLE_CHANGE", + targetType: AuditTargetType.USER, + targetId: person.id, + targetLabel: `${person.firstName} ${person.lastName}`, + targetKitaId: actor.kitaId, + metadata: { previousRole: person.role, newRole: input.data.role }, + }, + }); + // Auth reloads the role from the database on every request. Keeping sessionVersion + // preserves already-issued household alarm links and existing login sessions. + return { changed: true, ownRoleChanged: person.id === actor.id }; + }); +} + +export async function getKitaRoleHistory(kitaId: string) { + return prisma.auditLog.findMany({ + where: { targetKitaId: kitaId, action: "USER_ROLE_CHANGE" }, + orderBy: { createdAt: "desc" }, + take: 15, + select: { + id: true, + targetLabel: true, + actorEmail: true, + metadata: true, + createdAt: true, + }, + }); +} diff --git a/src/lib/kita-roles.ts b/src/lib/kita-roles.ts new file mode 100644 index 0000000..a058dc8 --- /dev/null +++ b/src/lib/kita-roles.ts @@ -0,0 +1,19 @@ +import type { UserRole } from "@prisma/client"; + +export const kitaRoleLabels: Record = { + ADMIN: "Kita-Admin", + KOORDINATOR: "Notdienst-Koordination", + ELTERN: "Eltern", + ERZIEHER: "Fachkraft", + SUPERADMIN: "Systemadmin", +}; + +export function availableKitaRoles(person: { + familyId: string | null; + hasStaffProfile: boolean; +}): UserRole[] { + if (person.hasStaffProfile) return ["ERZIEHER", "ADMIN"]; + return person.familyId + ? ["ELTERN", "KOORDINATOR", "ADMIN"] + : ["KOORDINATOR", "ADMIN"]; +} diff --git a/src/lib/staff/email.ts b/src/lib/staff/email.ts index d8f29c1..a446012 100644 --- a/src/lib/staff/email.ts +++ b/src/lib/staff/email.ts @@ -40,25 +40,29 @@ export async function deliverStaffEmails(ids: string[], send = sendAppEmail) { }, }); const { user, profile } = job; - let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now); + let eligible = + user.kitaId === profile.kitaId && + !user.disabledAt && + (!user.lockedUntil || user.lockedUntil <= now); if (job.inviteToken) { const token = await prisma.verificationToken.findUnique({ where: { token: hashToken(job.inviteToken) }, }); eligible &&= profile.userId === user.id && - user.role === "ERZIEHER" && + ["ERZIEHER", "ADMIN"].includes(user.role) && !user.emailVerifiedAt && !!token && token.identifier === user.id && token.expires > now; } else { eligible &&= - !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && + !!user.emailVerifiedAt && + !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now) && (job.toAdmins ? user.role === "ADMIN" - : user.role === "ERZIEHER" && + : ["ERZIEHER", "ADMIN"].includes(user.role) && profile.userId === user.id && user.staffDecisionEmails); } diff --git a/src/lib/staff/service.ts b/src/lib/staff/service.ts index 32a6016..67e14e9 100644 --- a/src/lib/staff/service.ts +++ b/src/lib/staff/service.ts @@ -81,7 +81,11 @@ async function inviteStaff( if ( existingUser && (existingUser.kitaId !== actor.kitaId || - existingUser.role !== UserRole.ERZIEHER || + !( + existingUser.role === UserRole.ERZIEHER || + (existingUser.role === UserRole.ADMIN && + existingUser.id === profile.userId) + ) || (await tx.staffProfile.count({ where: { userId: existingUser.id, id: { not: profile.id } }, }))) @@ -205,7 +209,9 @@ export function assertStaffAccess( throw new UserFacingError("Kein Zugriff auf dieses Personalprofil."); if (adminOnly) requireAdmin(actor); else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id) - throw new UserFacingError("Du kannst nur dein eigenes Personalprofil verwalten."); + throw new UserFacingError( + "Du kannst nur dein eigenes Personalprofil verwalten.", + ); } export async function getStaffProfile(actor: StaffActor, profileId?: string) { @@ -320,8 +326,8 @@ export async function executeStaffCommand( kitaId: actor.kitaId, role: actor.role, privacyPolicyAcceptedAt: { not: null }, - emailVerifiedAt: { not: null }, - disabledAt: null, + emailVerifiedAt: { not: null }, + disabledAt: null, }, }); if ( @@ -419,7 +425,9 @@ export async function executeStaffCommand( if ( profile.user && (profile.user.kitaId !== actor.kitaId || - profile.user.role !== UserRole.ERZIEHER) + !([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes( + profile.user.role, + )) ) throw new UserFacingError( "Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.", @@ -440,7 +448,11 @@ export async function executeStaffCommand( return { ...invitation, educatorId: educator.id }; } if (command === "emailPreference") { - if (actor.role !== UserRole.ERZIEHER) + if ( + !(await tx.staffProfile.count({ + where: { userId: actor.id, kitaId: actor.kitaId }, + })) + ) throw new UserFacingError( "Diese Einstellung ist für Fachkräfte vorgesehen.", ); @@ -534,7 +546,9 @@ export async function executeStaffCommand( input.employmentEnd && input.employmentEnd < input.employmentStart ) - throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn."); + throw new UserFacingError( + "Das Beschäftigungsende liegt vor dem Beginn.", + ); if ( input.trackingStart < input.employmentStart || (input.employmentEnd && input.trackingStart > input.employmentEnd) @@ -545,7 +559,8 @@ export async function executeStaffCommand( const educator = await tx.educator.findFirst({ where: { id: input.educatorId, kitaId: actor.kitaId }, }); - if (!educator) throw new UserFacingError("Fachkraft nicht gefunden."); + if (!educator) + throw new UserFacingError("Fachkraft nicht gefunden."); const existing = await tx.staffProfile.findFirst({ where: { educatorId: educator.id, kitaId: actor.kitaId }, include: profileInclude, @@ -591,7 +606,8 @@ export async function executeStaffCommand( where: { id: profileId, kitaId: actor.kitaId }, include: profileInclude, }); - if (!profile) throw new UserFacingError("Personalprofil nicht gefunden."); + if (!profile) + throw new UserFacingError("Personalprofil nicht gefunden."); assertStaffAccess(actor, profile); const holidays = new Set( ( @@ -631,7 +647,10 @@ export async function executeStaffCommand( for (const user of recipients) { if ( !toAdmins && - (user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails) + (!( + [UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[] + ).includes(user.role) || + !user.staffDecisionEmails) ) continue; const job = await tx.staffEmail.create({ @@ -719,7 +738,9 @@ export async function executeStaffCommand( input.employmentEnd && input.employmentEnd < dateKey(profile.employmentStart) ) - throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn."); + throw new UserFacingError( + "Das Beschäftigungsende liegt vor dem Beginn.", + ); if ( input.employmentEnd && (input.employmentEnd < dateKey(profile.trackingStart) || @@ -786,7 +807,9 @@ export async function executeStaffCommand( (profile.employmentEnd && input.date > dateKey(profile.employmentEnd)) ) - throw new UserFacingError("Der Tag liegt außerhalb der Beschäftigung."); + throw new UserFacingError( + "Der Tag liegt außerhalb der Beschäftigung.", + ); if (input.remove) await tx.staffDayOverride.deleteMany({ where: { profileId, date: dateOnly(input.date) }, @@ -859,7 +882,9 @@ export async function executeStaffCommand( }) .parse(data); if (input.date < dateKey(profile.trackingStart)) - throw new UserFacingError("Korrekturen müssen ab Erfassungsbeginn liegen."); + throw new UserFacingError( + "Korrekturen müssen ab Erfassungsbeginn liegen.", + ); await tx.staffAdjustment.create({ data: { profileId, @@ -887,7 +912,8 @@ export async function executeStaffCommand( throw new UserFacingError("Zeiteintrag nicht gefunden."); if (existing) ensureDayEditable(profile, dateKey(existing.date)); if (command === "deleteTime") { - if (!existing) throw new UserFacingError("Zeiteintrag nicht gefunden."); + if (!existing) + throw new UserFacingError("Zeiteintrag nicht gefunden."); await tx.staffTimeEntry.delete({ where: { id: existing.id } }); await history( "Arbeitszeit gelöscht", @@ -1135,7 +1161,8 @@ export async function executeStaffCommand( const existing = data.id ? profile.leaves.find((l) => l.id === idSchema.parse(data.id)) : undefined; - if (data.id && !existing) throw new UserFacingError("Antrag nicht gefunden."); + if (data.id && !existing) + throw new UserFacingError("Antrag nicht gefunden."); if (command === "leaveCancel") { if ( !existing || @@ -1182,7 +1209,9 @@ export async function executeStaffCommand( !existing || !["PENDING", "CANCEL_REQUESTED"].includes(existing.status) ) - throw new UserFacingError("Dieser Antrag ist nicht mehr offen."); + throw new UserFacingError( + "Dieser Antrag ist nicht mehr offen.", + ); const cancelling = existing.status === "CANCEL_REQUESTED"; if (input.approve) ensureRangeEditable( @@ -1284,7 +1313,9 @@ export async function executeStaffCommand( .parse(data); if (input.kind === "SICKNESS") requireAdmin(actor); if (existing && existing.status !== "PENDING") - throw new UserFacingError("Nur offene Anträge können bearbeitet werden."); + throw new UserFacingError( + "Nur offene Anträge können bearbeitet werden.", + ); if (input.startDate < dateKey(profile.trackingStart)) throw new UserFacingError( "Abwesenheiten bitte ab Erfassungsbeginn erfassen.", diff --git a/tests/kita-roles.integration.test.cjs b/tests/kita-roles.integration.test.cjs new file mode 100644 index 0000000..063da41 --- /dev/null +++ b/tests/kita-roles.integration.test.cjs @@ -0,0 +1,599 @@ +/* eslint-disable @typescript-eslint/no-require-imports -- Test real server code with isolated request and transport boundaries. */ +const { test, before, after, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const ts = require("typescript"); +const { PrismaClient } = require("@prisma/client"); +const { randomUUID } = require("node:crypto"); +let prisma, + current, + f, + change, + changeAction, + remove, + support, + updateFamily, + alarms, + alarmMail, + staff, + staffMail, + assign; +const emails = []; +const tenants = [], + looseUsers = []; +function loader(overrides = {}) { + const cache = new Map(); + function load(file) { + file = path.resolve(file); + if (cache.has(file)) return cache.get(file).exports; + const mod = { exports: {} }; + cache.set(file, mod); + const code = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + }, + fileName: file, + }).outputText; + const req = (spec) => { + if (Object.hasOwn(overrides, spec)) return overrides[spec]; + if (spec === "server-only") return {}; + if (spec === "@/lib/prisma") return { prisma }; + if (spec === "next/headers") + return { headers: async () => new Headers() }; + if (spec === "next/cache") return { revalidatePath() {} }; + if (spec === "next/navigation") + return { + redirect(url) { + throw new Error(`REDIRECT:${url}`); + }, + notFound() { + throw new Error("NOT_FOUND"); + }, + }; + if (spec.startsWith("@/") || spec.startsWith(".")) { + const target = spec.startsWith("@/") + ? path.resolve("src", spec.slice(2)) + : path.resolve(path.dirname(file), spec); + for (const suffix of [".ts", ".tsx", "/index.ts"]) + if (fs.existsSync(target + suffix)) return load(target + suffix); + } + return require(spec); + }; + vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { + filename: file, + })(req, mod, mod.exports); + return mod.exports; + } + return load; +} + +before(async () => { + const raw = process.env.TEST_DATABASE_URL; + assert.ok( + raw, + "TEST_DATABASE_URL muss ausdrücklich auf die separate lokale kita_roles_test zeigen.", + ); + const url = new URL(raw); + assert.ok(["127.0.0.1", "localhost"].includes(url.hostname)); + assert.equal(url.pathname, "/kita_roles_test"); + prisma = new PrismaClient({ datasourceUrl: raw }); + const load = loader({ + "@/lib/auth-utils": { + requireKitaSession: async () => ({ user: current }), + requireRole: async (roles) => { + assert.ok(roles.includes(current.role)); + return { user: current }; + }, + }, + "@/lib/mail": { + getAppEmailConfigError: () => null, + sendAppEmail: async (input) => { + emails.push(input); + return { success: true }; + }, + }, + }); + change = load("src/lib/kita-role-management.ts").changeKitaRole; + changeAction = load( + "src/app/dashboard/zugriffsrechte/actions.ts", + ).changeKitaRoleAction; + remove = load("src/app/dashboard/profil/actions.ts").deleteMyAccount; + support = load("src/app/admin/actions.ts"); + updateFamily = load( + "src/app/dashboard/families/actions.ts", + ).updateFamilyAction; + alarms = load("src/lib/alarms/service.ts"); + alarmMail = load("src/lib/alarms/email.ts"); + staff = load("src/lib/staff/service.ts"); + staffMail = load("src/lib/staff/email.ts"); + assign = load( + "src/app/dashboard/notdienst/plan/actions.ts", + ).updateAssignmentAction; + process.env.AUTH_URL = "http://127.0.0.1:3008"; +}); +async function user(role, extra = {}, kitaId = f.kita.id) { + return prisma.user.create({ + data: { + kitaId, + email: `${randomUUID()}@example.invalid`, + firstName: "Test", + lastName: role, + passwordHash: "test-only", + emailVerifiedAt: new Date(), + privacyPolicyAcceptedAt: new Date(), + role, + ...extra, + }, + }); +} +const actor = (u) => ({ + id: u.id, + kitaId: u.kitaId, + sessionVersion: u.sessionVersion, +}); +const changeTo = (u, role, by = f.admin) => + change(actor(by), { userId: u.id, role, expectedRole: u.role }); +beforeEach(async () => { + const kita = await prisma.kita.create({ + data: { name: "Rollen-Test", slug: `roles-${randomUUID()}` }, + }); + tenants.push(kita.id); + f = { kita }; + f.admin = await user("ADMIN"); + f.family = await prisma.family.create({ + data: { kitaId: kita.id, name: "Beispiel" }, + }); + f.parent = await user("ELTERN", { familyId: f.family.id }); + f.child = await prisma.child.create({ + data: { + kitaId: kita.id, + familyId: f.family.id, + firstName: "Kind", + lastName: "Test", + }, + }); + current = f.admin; + emails.length = 0; +}); +after(async () => { + if (!prisma) return; + await prisma.kita.deleteMany({ where: { id: { in: tenants } } }); + await prisma.user.deleteMany({ where: { id: { in: looseUsers } } }); + await prisma.auditLog.deleteMany({ + where: { targetKitaId: { in: tenants } }, + }); + await prisma.$disconnect(); +}); + +test("Eltern werden Koordination und Kita-Admin; Haushalt und Kind bleiben erhalten", async () => { + assert.equal( + ( + await changeAction({ + userId: f.parent.id, + expectedRole: "ELTERN", + role: "KOORDINATOR", + }) + ).success, + true, + ); + const coord = await prisma.user.findUniqueOrThrow({ + where: { id: f.parent.id }, + }); + await changeTo(coord, "ADMIN"); + const updated = await prisma.user.findUniqueOrThrow({ + where: { id: coord.id }, + }); + assert.equal(updated.familyId, f.family.id); + assert.equal(updated.sessionVersion, coord.sessionVersion); + assert.equal( + await prisma.child.count({ where: { familyId: f.family.id } }), + 1, + ); +}); +test("Rollenänderung protokolliert Akteur, Kita und vorherige/neue Rolle genau einmal", async () => { + await changeTo(f.parent, "ADMIN"); + const jobs = await prisma.auditLog.findMany({ + where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" }, + }); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].actorUserId, f.admin.id); + assert.deepEqual(jobs[0].metadata, { + previousRole: "ELTERN", + newRole: "ADMIN", + }); + assert.equal( + (await changeTo({ ...f.parent, role: "ADMIN" }, "ADMIN")).changed, + false, + ); + assert.equal( + await prisma.auditLog.count({ + where: { targetKitaId: f.kita.id, action: "USER_ROLE_CHANGE" }, + }), + 1, + ); +}); +test("Konto ohne Haushalt darf Koordination werden, aber nicht Eltern oder Fachkraft", async () => { + const person = await user("KOORDINATOR"); + await assert.rejects(changeTo(person, "ELTERN"), /Haushalt/); + await assert.rejects(changeTo(person, "ERZIEHER"), /Personalprofil/); + assert.equal((await changeTo(person, "ADMIN")).changed, true); +}); +test("Superadmin-Rolle kann weder vergeben noch verändert werden", async () => { + await assert.rejects(changeTo(f.parent, "SUPERADMIN"), /Ungültige/); + const root = await user("SUPERADMIN"); + await assert.rejects( + change(actor(f.admin), { + userId: root.id, + expectedRole: "ADMIN", + role: "KOORDINATOR", + }), + /nicht geändert/, + ); +}); +test("Fremde Kita ist bei Zielkonto und Akteur ausgeschlossen", async () => { + const foreign = await prisma.kita.create({ + data: { name: "Andere", slug: `roles-${randomUUID()}` }, + }); + tenants.push(foreign.id); + const outsider = await user("ADMIN", {}, foreign.id); + await assert.rejects(changeTo(outsider, "KOORDINATOR"), /nicht geändert/); + await assert.rejects( + change( + { ...actor(f.admin), kitaId: foreign.id }, + { userId: outsider.id, expectedRole: "ADMIN", role: "KOORDINATOR" }, + ), + /nicht mehr ändern/, + ); +}); +test("Eltern, gesperrte Admins und veraltete Sessions dürfen keine Rollen ändern", async () => { + await assert.rejects( + change(actor(f.parent), { + userId: f.admin.id, + expectedRole: "ADMIN", + role: "KOORDINATOR", + }), + /nicht mehr ändern/, + ); + await assert.rejects( + change( + { ...actor(f.admin), sessionVersion: 9 }, + { userId: f.parent.id, expectedRole: "ELTERN", role: "ADMIN" }, + ), + /nicht mehr ändern/, + ); + await prisma.user.update({ + where: { id: f.admin.id }, + data: { disabledAt: new Date() }, + }); + await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/); +}); +test("Veralteter Dialog überschreibt keine zwischenzeitliche Rollenänderung", async () => { + await changeTo(f.parent, "KOORDINATOR"); + await assert.rejects(changeTo(f.parent, "ADMIN"), /inzwischen geändert/); + assert.equal( + (await prisma.user.findUnique({ where: { id: f.parent.id } })).role, + "KOORDINATOR", + ); +}); +test("Letzter Admin kann seine Rolle nicht abgeben", async () => { + await assert.rejects( + changeTo(f.admin, "KOORDINATOR"), + /Mindestens ein aktiver/, + ); +}); +for (const [label, extra] of [ + ["offene Einladung", { emailVerifiedAt: null, passwordHash: "" }], + ["deaktiviert", { disabledAt: new Date() }], + ["gesperrt", { lockedUntil: new Date("2099-01-01") }], + ["ohne Zustimmung", { privacyPolicyAcceptedAt: null }], +]) { + test(`Ein weiterer Admin ${label} reicht nicht als Ersatz`, async () => { + await user("ADMIN", extra); + await assert.rejects( + changeTo(f.admin, "KOORDINATOR"), + /Mindestens ein aktiver/, + ); + }); +} +test("Mit zweitem aktivierten Admin ist eigene Herabstufung möglich und stale Admin-Rechte sind unwirksam", async () => { + await user("ADMIN"); + assert.equal((await changeTo(f.admin, "KOORDINATOR")).ownRoleChanged, true); + await assert.rejects(changeTo(f.parent, "ADMIN"), /nicht mehr ändern/); +}); +test("Parallele Herabstufung zweier Admins lässt genau einen Admin bestehen", async () => { + const second = await user("ADMIN"); + const results = await Promise.allSettled([ + changeTo(f.admin, "KOORDINATOR", f.admin), + changeTo(second, "KOORDINATOR", second), + ]); + assert.equal(results.filter((r) => r.status === "fulfilled").length, 1); + assert.equal( + await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }), + 1, + ); +}); +test("Eigene Kontolöschung ist für letzten Admin gesperrt", async () => { + assert.match((await remove()).error, /Mindestens ein aktiver/); + assert.ok(await prisma.user.findUnique({ where: { id: f.admin.id } })); +}); +test("Eigene Kontolöschung mit zweitem aktivem Admin funktioniert weiterhin", async () => { + await user("ADMIN"); + assert.equal((await remove()).success, true); + assert.equal( + await prisma.user.findUnique({ where: { id: f.admin.id } }), + null, + ); +}); +test("Superadmin kann den letzten Kita-Admin weder löschen noch sperren", async () => { + current = await user("SUPERADMIN", {}, null); + looseUsers.push(current.id); + current.authenticatedAt = Date.now(); + assert.match( + ( + await support.deleteUser({ + userId: f.admin.id, + confirmationEmail: f.admin.email, + }) + ).error, + /Mindestens ein aktiver/, + ); + assert.match( + (await support.lockUser(f.admin.id)).error, + /Mindestens ein aktiver/, + ); +}); +test("Support darf zusätzlichen Admin sperren und löschen", async () => { + const second = await user("ADMIN"); + current = await user("SUPERADMIN", {}, null); + looseUsers.push(current.id); + current.authenticatedAt = Date.now(); + assert.equal((await support.lockUser(second.id)).ok, true); + assert.equal( + ( + await support.deleteUser({ + userId: second.id, + confirmationEmail: second.email, + }) + ).ok, + true, + ); +}); +test("Gleichzeitige Admin-Löschung und Herabstufung lässt einen Admin bestehen", async () => { + const second = await user("ADMIN"); + const results = await Promise.allSettled([ + remove(), + changeTo(second, "KOORDINATOR"), + ]); + const successful = + results.filter((r) => r.status === "fulfilled" && r.value.success).length + + results.filter((r) => r.status === "fulfilled" && r.value.changed).length; + assert.equal(successful, 1); + assert.equal( + await prisma.user.count({ where: { kitaId: f.kita.id, role: "ADMIN" } }), + 1, + ); +}); +test("E-Mail-Änderung darf letzten aktivierten Admin nicht deaktivieren", async () => { + await prisma.user.update({ + where: { id: f.admin.id }, + data: { familyId: f.family.id }, + }); + const payload = { + familyId: f.family.id, + familyName: f.family.name, + parents: [ + f.parent, + { ...f.admin, email: `${randomUUID()}@example.invalid` }, + ], + children: [f.child], + }; + assert.match((await updateFamily(payload)).error, /Mindestens ein aktiver/); + assert.equal( + (await prisma.user.findUnique({ where: { id: f.admin.id } })).email, + f.admin.email, + ); +}); + +test("Fachkraft wird Admin; Personalprofil bleibt bearbeitbar und eigene Mailpräferenz erhalten", async () => { + const person = await user("ERZIEHER"); + const educator = await prisma.educator.create({ + data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" }, + }); + const profile = await prisma.staffProfile.create({ + data: { + kitaId: f.kita.id, + educatorId: educator.id, + userId: person.id, + employmentStart: new Date("2030-01-01"), + trackingStart: new Date("2030-01-01"), + }, + }); + await assert.rejects(changeTo(person, "KOORDINATOR"), /Personalprofil/); + await changeTo(person, "ADMIN"); + await staff.executeStaffCommand( + { id: person.id, kitaId: f.kita.id, role: "ADMIN" }, + { command: "emailPreference", data: { enabled: false } }, + ); + await staff.executeStaffCommand( + { id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" }, + { + command: "educatorUpdate", + data: { + educatorId: educator.id, + firstName: "Team", + lastName: "Geändert", + active: true, + email: person.email, + }, + }, + ); + assert.equal( + (await prisma.staffProfile.findUnique({ where: { id: profile.id } })) + .userId, + person.id, + ); + assert.equal( + (await prisma.user.findUnique({ where: { id: person.id } })) + .staffDecisionEmails, + false, + ); + await changeTo({ ...person, role: "ADMIN" }, "ERZIEHER"); +}); +test("Offene Fachkraft-Einladung lässt sich auch nach Admin-Ernennung erneut senden", async () => { + const person = await user("ERZIEHER", { + emailVerifiedAt: null, + passwordHash: "", + }); + const educator = await prisma.educator.create({ + data: { kitaId: f.kita.id, firstName: "Team", lastName: "Test" }, + }); + await prisma.staffProfile.create({ + data: { + kitaId: f.kita.id, + educatorId: educator.id, + userId: person.id, + employmentStart: new Date("2030-01-01"), + trackingStart: new Date("2030-01-01"), + }, + }); + await changeTo(person, "ADMIN"); + const queued = await staff.executeStaffCommand( + { id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" }, + { command: "educatorInvite", data: { educatorId: educator.id } }, + ); + assert.equal( + ( + await staffMail.deliverStaffEmails(queued.emailIds, async () => ({ + success: true, + })) + ).sent, + 1, + ); +}); +for (const role of ["ADMIN", "KOORDINATOR"]) + test(`Haushaltsalarme erreichen und akzeptieren Eltern mit Rolle ${role}`, async () => { + await changeTo(f.parent, role); + const plan = await prisma.notdienstPlan.create({ + data: { + kitaId: f.kita.id, + year: 2030, + month: 11, + status: "PUBLISHED", + assignments: { + create: { + kitaId: f.kita.id, + childId: f.child.id, + date: new Date("2030-11-04"), + }, + }, + }, + include: { assignments: true }, + }); + const job = await alarms.executeAlarmCommand( + { id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" }, + "create", + { assignmentId: plan.assignments[0].id, wholeDay: true }, + ); + const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({ + where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" }, + }); + const token = request.path.split("/").pop(); + assert.ok(await alarms.getPublicAlarm(token)); + assert.equal( + ( + await alarmMail.deliverAlarmEmails([request.id], async () => ({ + success: true, + })) + ).sent, + 1, + ); + await alarms.respondToAlarm(token, true); + assert.equal( + (await prisma.notdienstAlarm.findUnique({ where: { id: job.alarmId } })) + .status, + "CONFIRMED", + ); + }); +test("Bereits ausgestellter Haushalts-Alarmlink bleibt nach Ernennung gültig", async () => { + const plan = await prisma.notdienstPlan.create({ + data: { + kitaId: f.kita.id, + year: 2030, + month: 11, + status: "PUBLISHED", + assignments: { + create: { + kitaId: f.kita.id, + childId: f.child.id, + date: new Date("2030-11-04"), + }, + }, + }, + include: { assignments: true }, + }); + const job = await alarms.executeAlarmCommand( + { id: f.admin.id, kitaId: f.kita.id, role: "ADMIN" }, + "create", + { assignmentId: plan.assignments[0].id, wholeDay: true }, + ); + const request = await prisma.notdienstAlarmEmail.findFirstOrThrow({ + where: { alarmId: job.alarmId, userId: f.parent.id, kind: "REQUEST" }, + }); + await changeTo(f.parent, "ADMIN"); + assert.ok(await alarms.getPublicAlarm(request.path.split("/").pop())); +}); + +test("Letzter Admin im Ein-Personen-Haushalt bleibt auch vor kaskadierender Familienlöschung geschützt", async () => { + await prisma.user.update({ + where: { id: f.parent.id }, + data: { role: "ADMIN" }, + }); + await prisma.user.update({ + where: { id: f.admin.id }, + data: { role: "KOORDINATOR" }, + }); + current = { ...f.parent, role: "ADMIN" }; + assert.match((await remove()).error, /Mindestens ein aktiver/); + assert.ok(await prisma.family.findUnique({ where: { id: f.family.id } })); + assert.ok(await prisma.child.findUnique({ where: { id: f.child.id } })); +}); + +test("Veröffentlichte Umteilung informiert auch den Admin im betroffenen Elternhaushalt", async () => { + await changeTo(f.parent, "ADMIN"); + const family = await prisma.family.create({ + data: { kitaId: f.kita.id, name: "Ersatz" }, + }); + const parent = await user("ELTERN", { familyId: family.id }); + const child = await prisma.child.create({ + data: { + kitaId: f.kita.id, + familyId: family.id, + firstName: "Ersatz", + lastName: "Test", + }, + }); + const plan = await prisma.notdienstPlan.create({ + data: { + kitaId: f.kita.id, + year: 2030, + month: 11, + status: "PUBLISHED", + assignments: { + create: { + kitaId: f.kita.id, + childId: f.child.id, + date: new Date("2030-11-04"), + }, + }, + }, + }); + const result = await assign(plan.id, "2030-11-04", child.id); + assert.equal(result.success, true); + assert.ok(emails.some((email) => [email.to].flat().includes(f.parent.email))); + assert.ok(emails.some((email) => [email.to].flat().includes(parent.email))); +}); diff --git a/tests/notdienst-assignment-view.test.cjs b/tests/notdienst-assignment-view.test.cjs new file mode 100644 index 0000000..871a088 --- /dev/null +++ b/tests/notdienst-assignment-view.test.cjs @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-require-imports -- Render the actual client component through the TypeScript loader. */ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const ts = require("typescript"); +const React = require("react"); +const { renderToStaticMarkup } = require("react-dom/server"); +function loader(overrides = {}) { + const cache = new Map(); + function load(file) { + file = path.resolve(file); + if (cache.has(file)) return cache.get(file).exports; + const mod = { exports: {} }; + cache.set(file, mod); + const code = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + }, + fileName: file, + }).outputText; + const req = (spec) => { + if (Object.hasOwn(overrides, spec)) return overrides[spec]; + if (spec === "server-only") return {}; + if (spec === "@/lib/prisma") + throw new Error("No database in component render tests"); + if (spec === "next/headers") + return { headers: async () => new Headers() }; + if (spec === "next/cache") return { revalidatePath() {} }; + if (spec === "next/navigation") + return { + redirect(url) { + throw new Error(`REDIRECT:${url}`); + }, + notFound() { + throw new Error("NOT_FOUND"); + }, + }; + if (spec.startsWith("@/") || spec.startsWith(".")) { + const target = spec.startsWith("@/") + ? path.resolve("src", spec.slice(2)) + : path.resolve(path.dirname(file), spec); + for (const suffix of [".ts", ".tsx", "/index.ts"]) + if (fs.existsSync(target + suffix)) return load(target + suffix); + } + return require(spec); + }; + vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { + filename: file, + })(req, mod, mod.exports); + return mod.exports; + } + return load; +} + +const load = loader({ "./actions": {} }); +const { ManualAssignmentTable } = load( + "src/app/dashboard/notdienst/plan/plan-view.tsx", +); +const children = [ + { id: "a", name: "Alex", parentName: "Haushalt A", familyId: "family-a" }, + { id: "b", name: "Ben", parentName: "Haushalt B", familyId: "family-b" }, + { id: "a2", name: "Anna", parentName: "Haushalt A", familyId: "family-a" }, +]; +const day = { + date: "2030-11-04T00:00:00.000Z", + assignmentId: null, + childId: null, +}; +const familyStatuses = [ + { id: "family-a", selectedDays: [{ iso: day.date }] }, + { id: "family-b", selectedDays: [{ iso: "2030-11-05T00:00:00.000Z" }] }, +]; +const render = (overrides = {}) => + renderToStaticMarkup( + React.createElement(ManualAssignmentTable, { + days: [day], + allChildren: children, + familyStatuses, + isDraft: true, + isPending: false, + onAssignmentChange() {}, + ...overrides, + }), + ); +test("Verfügbare Haushalte stehen zuerst; Geschwister zählen als eine verfügbare Familie", () => { + const html = render(); + assert.ok( + html.indexOf("Verfügbar an diesem Tag") < + html.indexOf("Keine Verfügbarkeit für diesen Tag"), + ); + assert.match(html, /1 Familie ist verfügbar/); + assert.match(html, /value="a2"/); + assert.match(html, /value="b"/); + assert.match(html, /aria-label="Familie für 04.11.2030"/); +}); +test("Bestehende Einteilung ohne Rückmeldung bleibt auswählbar und wird kenntlich gemacht", () => { + const html = render({ days: [{ ...day, childId: "b" }] }); + assert.match(html, /value="b" selected/); + assert.match( + html, + /Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben/, + ); +}); +test("Eingeteilte verfügbare Familie und Haushaltseinteilungszahl werden angezeigt", () => { + const html = render({ + days: [ + { ...day, childId: "a" }, + { ...day, date: "2030-11-05T00:00:00.000Z", childId: "a2" }, + ], + }); + assert.match(html, /Eingeteilte Familie ist verfügbar/); + assert.match(html, /Haushalt A \(Alex\) — 2x/); +}); +test("Fehlende Verfügbarkeiten erzeugen eine klare Angabe ohne Familien auszublenden", () => { + const html = render({ familyStatuses: [] }); + assert.match(html, /0 Familien sind verfügbar/); + assert.match(html, /value="a"/); + assert.match(html, /value="b"/); +}); +test("Aktiver Alarm sperrt die Auswahl weiterhin", () => { + const html = render({ + days: [{ ...day, childId: "a", activeAlarmId: "alarm" }], + }); + assert.match(html, /]*disabled/); +}); +test("Ungültige alte Einteilung bleibt entfernbar; neue Kinder sind dafür gesperrt", () => { + const html = render({ + days: [{ ...day, childId: "inactive", isInvalid: true }], + }); + assert.match(html, /value="empty"/); + assert.match(html, /value="inactive" disabled/); + assert.match(html, /value="a" disabled/); + assert.match(html, /Einteilung nicht mehr gültig/); +});