feat: show planning availability and manage Kita access roles

This commit is contained in:
t.indorf
2026-10-12 16:28:56 +02:00
parent 99d3a63eed
commit 061024d901
29 changed files with 2347 additions and 404 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
Stand: 9. Oktober 2026 · geprüfter Code: `4702bf5abb0037ab9e0913dbef01ffbe21bbb96d` (`origin/main` zum Prüfzeitpunkt). 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 ## Umfang und Methode
+15
View File
@@ -1,5 +1,20 @@
# Kita-Planer – Funktionsübersicht und aktueller Stand # 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 ## 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. - 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.
+1
View File
@@ -30,6 +30,7 @@
"test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs", "test:calendar:integration": "node --test tests/calendar-actions.integration.test.cjs",
"test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs", "test:notdienst:notifications": "node --test tests/notdienst-notifications.integration.test.cjs",
"test:mail": "node --test tests/mail-transport.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:runtime": "node --test tests/runtime-supervisor.test.cjs",
"test:family-absence": "tsx --test tests/absence-policy.test.ts", "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" "test:family-absence:integration": "tsx --experimental-test-module-mocks --test tests/family-absence-flows.integration.test.ts"
+51 -14
View File
@@ -7,6 +7,12 @@ import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { requireRole } from "@/lib/auth-utils"; 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"; import { prisma } from "@/lib/prisma";
const deleteKitaSchema = z.object({ const deleteKitaSchema = z.object({
@@ -28,7 +34,8 @@ type ActionResult = {
async function requireSuperadminActor() { async function requireSuperadminActor() {
const session = await requireRole([UserRole.SUPERADMIN]); 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 { return {
id: session.user.id, id: session.user.id,
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin", email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
@@ -96,7 +103,10 @@ export async function deleteKita(
const counts = await getKitaDeleteCounts(kita.id); const counts = await getKitaDeleteCounts(kita.id);
const attachmentIds = await prisma.$transaction(async (tx) => { 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({ await tx.auditLog.create({
data: { data: {
actorUserId: actor.id, actorUserId: actor.id,
@@ -111,7 +121,7 @@ export async function deleteKita(
}); });
await tx.kita.delete({ where: { id: kita.id } }); await tx.kita.delete({ where: { id: kita.id } });
return attachments.map(item => item.id); return attachments.map((item) => item.id);
}); });
await removeAttachmentFiles(attachmentIds); await removeAttachmentFiles(attachmentIds);
@@ -156,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) {
return { error: "Benutzer wurde nicht gefunden." } as const; return { error: "Benutzer wurde nicht gefunden." } as const;
} }
if (user.id === actorId) { 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) { 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) { 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; return { user } as const;
@@ -188,8 +204,15 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
}; };
} }
const attachmentIds = await prisma.$transaction(async (tx) => { let attachmentIds: string[];
const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } }); 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({ await tx.verificationToken.deleteMany({
where: { identifier: user.id }, where: { identifier: user.id },
}); });
@@ -213,9 +236,12 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
}, },
}); });
await tx.user.delete({ where: { id: user.id } }); await tx.user.delete({ where: { id: user.id } });
return attachments.map(item => item.id); return attachments.map((item) => item.id);
}); });
} catch (error) {
if (!(error instanceof UserFacingError)) throw error;
return { ok: false, error: error.message };
}
await removeAttachmentFiles(attachmentIds); await removeAttachmentFiles(attachmentIds);
revalidatePath("/admin"); revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`); revalidatePath(`/admin/kitas/${user.kitaId}`);
@@ -245,10 +271,19 @@ async function updateUserSupportState(
const { user } = lookup; const { user } = lookup;
await prisma.$transaction(async (tx) => { 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({ await tx.user.update({
where: { id: user.id }, where: { id: user.id },
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) }, data: {
...data,
...(action === "USER_LOCK"
? { sessionVersion: { increment: 1 } }
: {}),
},
}); });
await tx.auditLog.create({ await tx.auditLog.create({
data: { data: {
@@ -267,7 +302,10 @@ async function updateUserSupportState(
}, },
}); });
}); });
} catch (error) {
if (!(error instanceof UserFacingError)) throw error;
return { ok: false, error: error.message };
}
revalidatePath("/admin"); revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`); revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit"); revalidatePath("/admin/audit");
@@ -296,4 +334,3 @@ export async function resetFailedLoginAttempts(
failedLoginAttempts: 0, failedLoginAttempts: 0,
}); });
} }
@@ -1,5 +1,9 @@
"use client"; "use client";
import {
RoleChangeButton,
type RolePerson,
} from "../../zugriffsrechte/role-change-button";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -47,6 +51,7 @@ type EducatorDto = {
email: string | null; email: string | null;
verified: boolean; verified: boolean;
inviteFailed: boolean; inviteFailed: boolean;
account: RolePerson | null;
}; };
function AccessFields({ function AccessFields({
@@ -119,9 +124,11 @@ function AccessFields({
} }
export function ErzieherList({ export function ErzieherList({
currentUserId,
educators, educators,
today, today,
}: { }: {
currentUserId: string;
educators: EducatorDto[]; educators: EducatorDto[];
today: string; today: string;
}) { }) {
@@ -377,6 +384,14 @@ export function ErzieherList({
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="pr-6 text-right"> <TableCell className="pr-6 text-right">
{ed.account ? (
<span className="mr-2 inline-block">
<RoleChangeButton
person={ed.account}
ownAccount={ed.account.id === currentUserId}
/>
</span>
) : null}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
+19 -1
View File
@@ -21,7 +21,15 @@ export default async function ErzieherPage() {
staffProfile: { staffProfile: {
select: { select: {
id: true, id: true,
user: { select: { email: true, emailVerifiedAt: true } }, user: {
select: {
id: true,
role: true,
familyId: true,
email: true,
emailVerifiedAt: true,
},
},
emails: { emails: {
where: { inviteToken: { not: null }, skippedAt: null }, where: { inviteToken: { not: null }, skippedAt: null },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
@@ -65,10 +73,20 @@ export default async function ErzieherPage() {
</header> </header>
<ErzieherList <ErzieherList
currentUserId={session.user.id}
today={todayKey()} today={todayKey()}
educators={educators.map(({ staffProfile, ...educator }) => ({ educators={educators.map(({ staffProfile, ...educator }) => ({
...educator, ...educator,
hasProfile: !!staffProfile, hasProfile: !!staffProfile,
account: staffProfile?.user
? {
id: staffProfile.user.id,
name: `${educator.firstName} ${educator.lastName}`,
role: staffProfile.user.role,
familyId: staffProfile.user.familyId,
hasStaffProfile: true,
}
: null,
email: staffProfile?.user?.email ?? null, email: staffProfile?.user?.email ?? null,
verified: !!staffProfile?.user?.emailVerifiedAt, verified: !!staffProfile?.user?.emailVerifiedAt,
inviteFailed: !!staffProfile?.emails[0]?.failed, inviteFailed: !!staffProfile?.emails[0]?.failed,
@@ -1,5 +1,8 @@
"use client"; "use client";
import type { UserRole } from "@prisma/client";
import { RoleChangeButton } from "../../zugriffsrechte/role-change-button";
import { RoleBadge } from "@/components/ui/role-badge";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react"; import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -29,6 +32,8 @@ interface FamilyUser {
email: string; email: string;
emailVerifiedAt: Date | null; emailVerifiedAt: Date | null;
invitation: FamilyInvitationView; invitation: FamilyInvitationView;
role: UserRole;
staffProfile: { id: string } | null;
dutyAssignments: { dutyAssignments: {
id: string; id: string;
dutyId: string; dutyId: string;
@@ -60,12 +65,14 @@ interface DutyData {
} }
interface FamiliesListClientProps { interface FamiliesListClientProps {
currentUserId: string;
initialFamilies: FamilyData[]; initialFamilies: FamilyData[];
allDuties: DutyData[]; allDuties: DutyData[];
canManageDuties: boolean; canManageDuties: boolean;
} }
export function FamiliesListClient({ export function FamiliesListClient({
currentUserId,
initialFamilies, initialFamilies,
allDuties, allDuties,
canManageDuties, canManageDuties,
@@ -85,7 +92,7 @@ export function FamiliesListClient({
(user) => (user) =>
user.firstName.toLowerCase().includes(q) || user.firstName.toLowerCase().includes(q) ||
user.lastName.toLowerCase().includes(q) || user.lastName.toLowerCase().includes(q) ||
user.email.toLowerCase().includes(q) user.email.toLowerCase().includes(q),
); );
if (matchesParent) return true; if (matchesParent) return true;
@@ -93,20 +100,20 @@ export function FamiliesListClient({
const matchesChild = family.children.some( const matchesChild = family.children.some(
(child) => (child) =>
child.firstName.toLowerCase().includes(q) || child.firstName.toLowerCase().includes(q) ||
child.lastName.toLowerCase().includes(q) child.lastName.toLowerCase().includes(q),
); );
return matchesChild; return matchesChild;
}); });
const childCount = filteredFamilies.reduce( const childCount = filteredFamilies.reduce(
(acc, family) => acc + family.children.length, (acc, family) => acc + family.children.length,
0 0,
); );
const pendingInviteCount = filteredFamilies.reduce( const pendingInviteCount = filteredFamilies.reduce(
(acc, family) => (acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length, acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0 0,
); );
return ( return (
@@ -123,14 +130,17 @@ export function FamiliesListClient({
</em> </em>
</h1> </h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]"> <p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort pflegen. Haushalte, Elternteile, Kinder und Ämter an einem ruhigen Ort
pflegen.
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2"> <div className="flex shrink-0 flex-wrap gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")} onClick={() =>
setViewMode(viewMode === "table" ? "cards" : "table")
}
className="flex items-center gap-1.5" className="flex items-center gap-1.5"
> >
{viewMode === "table" ? ( {viewMode === "table" ? (
@@ -152,7 +162,9 @@ export function FamiliesListClient({
<div className="mb-5 flex flex-wrap items-center justify-between gap-3"> <div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]"> <span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span> <span className="text-[var(--ink)] tabular-nums">
{filteredFamilies.length}
</span>
Familien Familien
</span> </span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]"> <span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
@@ -160,7 +172,9 @@ export function FamiliesListClient({
Kinder Kinder
</span> </span>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]"> <span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-semibold text-[var(--ink-soft)]">
<span className="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span> <span className="text-[var(--ink)] tabular-nums">
{pendingInviteCount}
</span>
Offene Einladungen Offene Einladungen
</span> </span>
</div> </div>
@@ -196,7 +210,8 @@ export function FamiliesListClient({
<div> <div>
<CardTitle>Haushalte</CardTitle> <CardTitle>Haushalte</CardTitle>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]"> <p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
{filteredFamilies.length} {filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "} {filteredFamilies.length}{" "}
{filteredFamilies.length === 1 ? "Familie" : "Familien"} ·{" "}
{childCount} Kinder · {pendingInviteCount} offene Einladungen {childCount} Kinder · {pendingInviteCount} offene Einladungen
</p> </p>
</div> </div>
@@ -218,7 +233,7 @@ export function FamiliesListClient({
<TableBody> <TableBody>
{filteredFamilies.map((family) => { {filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some( const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt (user) => !user.emailVerifiedAt,
); );
return ( return (
@@ -250,10 +265,29 @@ export function FamiliesListClient({
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" /> <Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]"> <span className="break-words [overflow-wrap:anywhere]">
{parent.email} {parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />} {!parent.emailVerifiedAt && (
<ResendInviteButton userId={parent.id} />
)}
</span> </span>
</div> </div>
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} /> <InvitationStatus
userId={parent.id}
name={`${parent.firstName} ${parent.lastName}`}
invitation={parent.invitation}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<RoleBadge role={parent.role} />
<RoleChangeButton
ownAccount={parent.id === currentUserId}
person={{
id: parent.id,
name: `${parent.firstName} ${parent.lastName}`,
role: parent.role,
familyId: family.id,
hasStaffProfile: !!parent.staffProfile,
}}
/>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -277,7 +311,9 @@ export function FamiliesListClient({
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}> <Badge
variant={hasPendingInvite ? "warning" : "success"}
>
{hasPendingInvite ? "Einladung offen" : "Aktiv"} {hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -323,7 +359,7 @@ export function FamiliesListClient({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFamilies.map((family) => { {filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some( const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt (user) => !user.emailVerifiedAt,
); );
return ( return (
<Card key={family.id} className="flex flex-col h-full"> <Card key={family.id} className="flex flex-col h-full">
@@ -333,9 +369,12 @@ export function FamiliesListClient({
{family.name.slice(0, 1)} {family.name.slice(0, 1)}
</div> </div>
<div> <div>
<CardTitle className="text-lg font-semibold">{family.name}</CardTitle> <CardTitle className="text-lg font-semibold">
{family.name}
</CardTitle>
<p className="text-xs text-[var(--ink-muted)]"> <p className="text-xs text-[var(--ink-muted)]">
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"} {family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</p> </p>
</div> </div>
</div> </div>
@@ -359,10 +398,29 @@ export function FamiliesListClient({
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" /> <Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]"> <span className="break-words [overflow-wrap:anywhere]">
{parent.email} {parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />} {!parent.emailVerifiedAt && (
<ResendInviteButton userId={parent.id} />
)}
</span> </span>
</div> </div>
<InvitationStatus userId={parent.id} name={`${parent.firstName} ${parent.lastName}`} invitation={parent.invitation} /> <InvitationStatus
userId={parent.id}
name={`${parent.firstName} ${parent.lastName}`}
invitation={parent.invitation}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<RoleBadge role={parent.role} />
<RoleChangeButton
ownAccount={parent.id === currentUserId}
person={{
id: parent.id,
name: `${parent.firstName} ${parent.lastName}`,
role: parent.role,
familyId: family.id,
hasStaffProfile: !!parent.staffProfile,
}}
/>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -430,8 +488,21 @@ export function FamiliesListClient({
function ResendInviteButton({ userId }: { userId: string }) { function ResendInviteButton({ userId }: { userId: string }) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => { return (
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await resendParentInvite(userId); const result = await resendParentInvite(userId);
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet."); if (result.error) toast.error(result.error);
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>; else toast.success("Einladung erneut gesendet.");
})
}
>
{pending ? "Wird gesendet…" : "Einladung erneut senden"}
</Button>
);
} }
+100 -21
View File
@@ -11,6 +11,10 @@ import {
queueFamilyInvitation, queueFamilyInvitation,
resendFamilyInvitation, resendFamilyInvitation,
} from "@/lib/family-invitations"; } from "@/lib/family-invitations";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning"; import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error"; import { UserFacingError, publicActionError } from "@/lib/action-error";
@@ -149,7 +153,12 @@ export async function addFamilyAction(
return { errors: { _form: ["Kein Mandant zugeordnet."] } }; 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)); const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
if (!parsedFamily.success) { if (!parsedFamily.success) {
@@ -310,8 +319,11 @@ export async function updateFamilyAction(
} }
const removedChildIdSet = new Set(removedChildIds); const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id)); const activeChildren = children.filter(
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." }; (child) => !removedChildIdSet.has(child.id),
);
if (activeChildren.length + newChildren.length > 20)
return { error: "Maximal 20 Kinder pro Familie." };
if (activeChildren.length + newChildren.length === 0) { if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." }; return { error: "Eine Familie benötigt mindestens ein Kind." };
} }
@@ -341,13 +353,26 @@ export async function updateFamilyAction(
return { error: "Familie wurde nicht gefunden." }; 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 (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 existingParentIds = new Set(
const existingChildIds = new Set(existingFamily.children.map((child) => child.id)); existingFamily.users.map((user) => user.id),
);
const existingChildIds = new Set(
existingFamily.children.map((child) => child.id),
);
if (!parents.every((parent) => existingParentIds.has(parent.id))) { if (!parents.every((parent) => existingParentIds.has(parent.id))) {
return { return {
@@ -367,23 +392,51 @@ export async function updateFamilyAction(
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." }; return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
} }
await prisma.$transaction(async (tx) => { 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); await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } }); const removed = await tx.child.count({
if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie."); 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({ await tx.family.update({
where: { id: familyId }, where: { id: familyId },
data: { name: familyName }, data: { name: familyName },
}); });
for (const parent of parents) { for (const parent of parents) {
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } }); const current = await tx.user.findFirst({
where: { id: parent.id, kitaId, familyId },
select: { email: true },
});
if (!current) throw new Error("Parent assignment changed"); if (!current) throw new Error("Parent assignment changed");
const emailChanged = current.email !== parent.email; const emailChanged = current.email !== parent.email;
await tx.user.update({ await tx.user.update({
where: { id: parent.id, kitaId, familyId }, where: { id: parent.id, kitaId, familyId },
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email, data: {
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) }, firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
...(emailChanged
? {
passwordHash: "",
emailVerifiedAt: null,
sessionVersion: { increment: 1 },
}
: {}),
},
}); });
if (emailChanged) { if (emailChanged) {
const invite = await queueFamilyInvitation( const invite = await queueFamilyInvitation(
@@ -439,7 +492,9 @@ export async function updateFamilyAction(
}, },
}); });
} }
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); },
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
} catch (error) { } catch (error) {
if ( if (
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
@@ -448,8 +503,14 @@ export async function updateFamilyAction(
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." }; return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
} }
if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error); if (!(error instanceof UserFacingError))
return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") }; console.error("Fehler beim Aktualisieren der Familie:", error);
return {
error: publicActionError(
error,
"Familie konnte nicht aktualisiert werden.",
),
};
} }
const warning = await sendInviteJobs(inviteJobs); const warning = await sendInviteJobs(inviteJobs);
@@ -464,15 +525,33 @@ export async function resendFamilyInvitationAction(
userId: string, userId: string,
): Promise<{ success?: boolean; error?: string; warning?: string }> { ): Promise<{ success?: boolean; error?: string; warning?: string }> {
const session = await requireRole([UserRole.ADMIN]); 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." }; 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." }; 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." }; return { error: "Bitte versuche es später erneut." };
const recipient = await prisma.user.findFirst({ const recipient = await prisma.user.findFirst({
where: { id: userId, kitaId: session.user.kitaId, familyId: { not: null }, where: {
emailVerifiedAt: null, passwordHash: "", disabledAt: null }, id: userId,
kitaId: session.user.kitaId,
familyId: { not: null },
emailVerifiedAt: null,
passwordHash: "",
disabledAt: null,
},
select: { email: true }, select: { email: true },
}); });
if (!recipient) return { error: "Keine offene Einladung gefunden." }; if (!recipient) return { error: "Keine offene Einladung gefunden." };
+14 -2
View File
@@ -32,9 +32,17 @@ export default async function FamiliesPage() {
firstName: true, firstName: true,
lastName: true, lastName: true,
email: true, email: true,
role: true,
staffProfile: { select: { id: true } },
emailVerifiedAt: true, emailVerifiedAt: true,
familyInvitation: { familyInvitation: {
select: { expiresAt: true, sentAt: true, failed: true, leaseUntil: true, status: true }, select: {
expiresAt: true,
sentAt: true,
failed: true,
leaseUntil: true,
status: true,
},
}, },
dutyAssignments: { dutyAssignments: {
select: { select: {
@@ -77,11 +85,15 @@ export default async function FamiliesPage() {
return ( return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<FamiliesListClient <FamiliesListClient
currentUserId={session.user.id}
initialFamilies={families.map((family) => ({ initialFamilies={families.map((family) => ({
...family, ...family,
users: family.users.map(({ familyInvitation, ...parent }) => ({ users: family.users.map(({ familyInvitation, ...parent }) => ({
...parent, ...parent,
invitation: familyInvitationView(parent.emailVerifiedAt, familyInvitation), invitation: familyInvitationView(
parent.emailVerifiedAt,
familyInvitation,
),
})), })),
}))} }))}
allDuties={allDuties} allDuties={allDuties}
+157 -35
View File
@@ -15,7 +15,11 @@ import { getTargetMonthData } from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning"; import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { todayKey } from "@/lib/alarms/format"; import { todayKey } from "@/lib/alarms/format";
import { serializableTransaction } from "@/lib/transaction"; 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 { sendAppEmail } from "@/lib/mail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail"; import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth"; import { requireAlarmActor } from "@/lib/alarms/auth";
@@ -43,14 +47,20 @@ export async function generatePlanAction(year?: number, month?: number) {
targetMonth = data.targetMonth; targetMonth = data.targetMonth;
} }
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." }; if (!validPlanMonth(targetYear, targetMonth))
return { error: "Ungültiger Planungsmonat." };
try { try {
await serializableTransaction(async (tx) => { await serializableTransaction(async (tx) => {
const existingPlan = await tx.notdienstPlan.findUnique({ 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([ const [availabilities, workingDays] = await Promise.all([
tx.notdienstAvailability.findMany({ tx.notdienstAvailability.findMany({
@@ -69,16 +79,29 @@ export async function generatePlanAction(year?: number, month?: number) {
const familyUsageCount = new Map<string, number>(); const familyUsageCount = new Map<string, number>();
const assignments: { childId: string; date: Date }[] = []; const assignments: { childId: string; date: Date }[] = [];
for (const day of workingDays) { 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; if (!candidates.length) continue;
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0)); const minUsage = Math.min(
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage); ...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0),
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)]; );
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 }); assignments.push({ childId: selected.childId, date: day });
familyUsageCount.set(selected.child.familyId, minUsage + 1); familyUsageCount.set(selected.child.familyId, minUsage + 1);
} }
const plan = await tx.notdienstPlan.create({ 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) { if (assignments.length) {
await tx.notdienstAssignment.createMany({ await tx.notdienstAssignment.createMany({
@@ -91,7 +114,9 @@ export async function generatePlanAction(year?: number, month?: number) {
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error(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) => { await serializableTransaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({ const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId }, 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) throw new UserFacingError("Plan nicht gefunden.");
if (plan.status === NotdienstPlanStatus.PUBLISHED) return; 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 days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
const validDates = new Set(days.map((day) => day.getTime())); const validDates = new Set(days.map((day) => day.getTime()));
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) { if (
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren."); 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({ await tx.notdienstPlan.update({
where: { id: planId, kitaId }, 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/plan");
revalidatePath("/dashboard/notdienst"); revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard/kalender"); 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"); revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery }; return { success: true, ...delivery };
} catch (error) { } 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 actor = await requireAlarmActor();
const kitaId = actor.kitaId; 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 [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy)); 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."); throw new UserFacingError("Plan kann nicht bearbeitet werden.");
} }
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) { if (
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat."); 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. // Clearing obsolete entries remains possible after a new closure.
if (newChildId) { 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())) { 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: { family: {
include: { include: {
users: { 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 }, select: { email: true, firstName: true, lastName: true },
}, },
}, },
@@ -217,7 +292,18 @@ export async function updateAssignmentAction(
family: { family: {
include: { include: {
users: { 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 }, select: { email: true, firstName: true, lastName: true },
}, },
}, },
@@ -392,7 +478,15 @@ export async function updateKitaNotdienstSettingsAction(
return { error: "Kein Mandant zugeordnet." }; 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 { try {
await prisma.kita.update({ 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 session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!; const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." }; if (!validPlanMonth(year, month))
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." }; 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 { try {
const queued = await queueNotdienstReminders(kitaId, year, month); 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"); revalidatePath("/dashboard/notdienst/plan");
return { success: true, queued, count: delivery.sent, ...delivery }; return { success: true, queued, count: delivery.sent, ...delivery };
} catch (error) { } 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) { export async function retryNotdienstEmailsAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!; const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." }; if (!validPlanMonth(year, month))
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." }; return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed)
return { error: "Bitte warte vor einem weiteren Versandversuch." };
try { 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 }); const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
revalidatePath("/dashboard/notdienst/plan"); revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery }; return { success: true, ...delivery };
} catch { } catch {
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." }; return {
error: "Der Versand bleibt gespeichert und wird erneut versucht.",
};
} }
} }
+39 -12
View File
@@ -4,7 +4,11 @@ import { de } from "date-fns/locale";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { prisma } from "@/lib/prisma"; 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 { loadNotdienstDays } from "@/lib/notdienst-planning";
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications"; import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
import { EmailStatusCard } from "./_components/email-status-card"; 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. // 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({ const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true }, where: { kitaId, active: true },
select: { select: {
@@ -146,8 +148,11 @@ export default async function PlanungsZentralePage({
}); });
const validDateKeys = new Set(workingDays.map(formatUtcDateKey)); const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date))); const availabilities = rawAvailabilities.filter((a) =>
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length; validDateKeys.has(formatUtcDateKey(a.date)),
);
const closureCount =
getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
const availabilityDatesByFamily = new Map<string, Set<string>>(); const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => { availabilities.forEach((availability) => {
@@ -186,12 +191,16 @@ export default async function PlanungsZentralePage({
// Wir mappen die Assignments auf die Werktage // Wir mappen die Assignments auf die Werktage
// Keep obsolete assignments visible so the coordinator can remove them. // 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 ?? []) { for (const assignment of plan?.assignments ?? []) {
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date); displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
} }
const activeChildIds = new Set(allChildren.map((child) => child.id)); 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 daysWithAssignments = Array.from(displayedDays.values())
.sort((a, b) => a.getTime() - b.getTime())
.map((day) => {
const assignment = plan?.assignments.find( const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime(), (a) => a.date.getTime() === day.getTime(),
); );
@@ -199,7 +208,9 @@ export default async function PlanungsZentralePage({
!assignment || !assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt); (plan?.publishedAt && assignment.createdAt > plan.publishedAt);
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId); const isInvalid =
!validDateKeys.has(formatUtcDateKey(day)) ||
(!!assignment && !activeChildIds.has(assignment.childId));
return { return {
date: day.toISOString(), date: day.toISOString(),
isInvalid, isInvalid,
@@ -208,15 +219,24 @@ export default async function PlanungsZentralePage({
isOriginallyUnassigned: !!isOriginallyUnassigned, isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null, activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm: canAlarm:
!!assignment && !isInvalid && !!assignment &&
!isInvalid &&
plan?.status === "PUBLISHED" && plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(), 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 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 ( return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5"> <div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
@@ -261,7 +281,14 @@ export default async function PlanungsZentralePage({
</div> </div>
</header> </header>
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} /> <EmailStatusCard
status={mailStatus}
year={targetYear}
month={targetMonth}
remindersOpen={remindersOpen}
daysBefore={kita.notdienstReminderDaysBefore}
jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32}
/>
<PlanView <PlanView
planId={plan?.id} planId={plan?.id}
+136 -31
View File
@@ -121,8 +121,14 @@ export function PlanView({
if ("error" in result && result.error) { if ("error" in result && result.error) {
toast.error(result.error as string); toast.error(result.error as string);
} else if ("success" in result && result.success) { } else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus."); if (result.failed || ("deferred" in result && result.deferred))
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet."); toast.warning(
"Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.",
);
else
toast.success(
"Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.",
);
} }
}); });
}; };
@@ -133,8 +139,14 @@ export function PlanView({
if ("error" in result && result.error) { if ("error" in result && result.error) {
toast.error(result.error as string); toast.error(result.error as string);
} else if ("success" in result && result.success) { } else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`); if (result.failed || ("deferred" in result && result.deferred))
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`); toast.warning(
`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`,
);
else
toast.success(
`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`,
);
} }
}); });
}; };
@@ -157,7 +169,11 @@ export function PlanView({
const valueToSet = newChildId === "empty" ? null : newChildId; const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => { startTransition(async () => {
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) { if ("error" in result && result.error) {
toast.error(result.error as string); toast.error(result.error as string);
} else { } else {
@@ -248,7 +264,10 @@ export function PlanView({
{family.childCount === 1 ? "" : "er"} {family.childCount === 1 ? "" : "er"}
</p> </p>
</div> </div>
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} /> <FamilyStatusPill
selectedDays={family.selectedDays.length}
requiredDays={family.requiredDays}
/>
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block"> <div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0 {family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ") ? family.selectedDays.map((day) => day.label).join(" · ")
@@ -318,8 +337,8 @@ export function PlanView({
Plan automatisch generieren Plan automatisch generieren
</h3> </h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]"> <p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "} Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren
die verfügbaren Familien. Familien.
</p> </p>
<details className="mt-3 text-xs text-[var(--ink-soft)]"> <details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]"> <summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
@@ -412,6 +431,7 @@ export function PlanView({
<ManualAssignmentTable <ManualAssignmentTable
days={days} days={days}
allChildren={allChildren} allChildren={allChildren}
familyStatuses={familyStatuses}
isDraft={isDraft} isDraft={isDraft}
isPending={isPending} isPending={isPending}
onAssignmentChange={handleAssignmentChange} onAssignmentChange={handleAssignmentChange}
@@ -451,7 +471,13 @@ export function PlanView({
); );
} }
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) { function FamilyStatusPill({
selectedDays,
requiredDays,
}: {
selectedDays: number;
requiredDays: number;
}) {
if (selectedDays === 0) { if (selectedDays === 0) {
return ( return (
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"> <span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
@@ -461,7 +487,13 @@ function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number
} }
return ( return (
<span className={selectedDays >= 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)]"}> <span
className={
selectedDays >= 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 {selectedDays} / {requiredDays} Tage
</span> </span>
); );
@@ -552,7 +584,11 @@ function PlanPreviewTable({
{format(dateObj, "EEEE", { locale: de })} {format(dateObj, "EEEE", { locale: de })}
</div> </div>
<div className="text-[var(--ink)]"> <div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"} {assignedChild
? assignedChild.parentName
: day.childId
? "Inaktives Kind"
: "Unbesetzt"}
{assignedChild && ( {assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]"> <span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name}) ({assignedChild.name})
@@ -576,8 +612,20 @@ function PlanPreviewTable({
</div> </div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div> <div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div> <div>
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}> <Badge
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"} variant={
day.isInvalid
? "warning"
: assignedChild
? "success"
: "destructive"
}
>
{day.isInvalid
? "Bitte korrigieren"
: assignedChild
? "Besetzt"
: "Offen"}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -588,17 +636,29 @@ function PlanPreviewTable({
); );
} }
function ManualAssignmentTable({ export function ManualAssignmentTable({
days, days,
allChildren, allChildren,
familyStatuses,
isDraft, isDraft,
isPending, isPending,
onAssignmentChange, onAssignmentChange,
}: Pick<PlanViewProps, "days" | "allChildren"> & { }: Pick<PlanViewProps, "days" | "allChildren" | "familyStatuses"> & {
isDraft: boolean; isDraft: boolean;
isPending: boolean; isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void; 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 // Map childId to familyId for quick lookup
const childFamilyMap = useMemo(() => { const childFamilyMap = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -626,7 +686,12 @@ function ManualAssignmentTable({
return ( return (
<Card className="overflow-hidden p-0"> <Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]"> <div className="border-b border-[var(--hairline-soft)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
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.
</div>
<div className="hidden sm:grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
<div>Datum</div> <div>Datum</div>
<div>Eingeteilte Familie</div> <div>Eingeteilte Familie</div>
</div> </div>
@@ -634,17 +699,41 @@ function ManualAssignmentTable({
{days.map((day) => { {days.map((day) => {
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number); const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy); 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]) => (
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) —{" "}
{familyAssignmentCounts.get(child.familyId) ?? 0}x
</option>
);
return ( return (
<div <div
key={day.date} key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]" className="grid gap-3 sm:grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
> >
<div className="font-medium text-[var(--ink)]"> <div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })} {format(dateObj, "EE, dd.MM.", { locale: de })}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="w-full max-w-sm space-y-2">
<select <select
aria-label={`Familie für ${format(dateObj, "dd.MM.yyyy")}`}
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]" className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"} value={day.childId || "empty"}
disabled={isPending || !!day.activeAlarmId} disabled={isPending || !!day.activeAlarmId}
@@ -653,23 +742,39 @@ function ManualAssignmentTable({
} }
> >
<option value="empty">-- Unbesetzt --</option> <option value="empty">-- Unbesetzt --</option>
{day.childId && !allChildren.some((child) => child.id === day.childId) ? ( {day.childId &&
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option> !allChildren.some((child) => child.id === day.childId) ? (
) : null} <option value={day.childId} disabled>
{allChildren.map((child) => { Inaktives Kind – bitte Einteilung entfernen
const famId = child.familyId;
const count = famId
? (familyAssignmentCounts.get(famId) ?? 0)
: 0;
return (
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) — {count}x
</option> </option>
); ) : null}
})} {available.length > 0 ? (
<optgroup label="Verfügbar an diesem Tag">
{available.map(option)}
</optgroup>
) : null}
{unavailable.length > 0 ? (
<optgroup label="Keine Verfügbarkeit für diesen Tag">
{unavailable.map(option)}
</optgroup>
) : null}
</select> </select>
{!day.isInvalid ? (
<p
className={`text-xs leading-5 ${(selectedChild && !selectedAvailable) || availableFamilies === 0 ? "text-[var(--warn)]" : "text-[var(--ink-soft)]"}`}
>
{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.`}
</p>
) : null}
</div>
{day.isInvalid ? ( {day.isInvalid ? (
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span> <span className="text-xs text-[var(--danger)]">
Einteilung nicht mehr gültig – bitte entfernen.
</span>
) : null} ) : null}
{!isDraft && ( {!isDraft && (
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0"> <span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
+105 -82
View File
@@ -11,6 +11,10 @@ import {
parseDateInput, parseDateInput,
} from "@/lib/child-birthdate"; } from "@/lib/child-birthdate";
import { serializableTransaction } from "@/lib/transaction"; import { serializableTransaction } from "@/lib/transaction";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning"; import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error"; import { UserFacingError, publicActionError } from "@/lib/action-error";
@@ -59,7 +63,11 @@ const childSchema = z
}); });
const familySchema = z.object({ 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({ 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, // Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
// damit der User nicht nur "Ungültige Eingabedaten" sieht. // damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage( function firstZodMessage(error: z.ZodError, fallback: string): string {
error: z.ZodError,
fallback: string,
): string {
const issue = error.issues[0]; const issue = error.issues[0];
return issue?.message || fallback; return issue?.message || fallback;
} }
@@ -121,8 +126,13 @@ export async function createMyChild(rawPayload: unknown) {
} }
try { try {
await serializableTransaction(async tx => { await serializableTransaction(async (tx) => {
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } }); 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"); if (count >= 20) throw new Error("Household child limit reached");
await tx.child.create({ await tx.child.create({
data: { data: {
@@ -166,10 +176,17 @@ export async function createMyFamily(rawPayload: unknown) {
}); });
const attached = await tx.user.updateMany({ 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 }, 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"); revalidatePath("/dashboard/profil");
@@ -178,7 +195,12 @@ export async function createMyFamily(rawPayload: unknown) {
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true }; return { success: true };
} catch (error) { } 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); console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
} }
return { error: "Der Haushalt konnte nicht angelegt werden." }; return { error: "Der Haushalt konnte nicht angelegt werden." };
@@ -249,7 +271,11 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
} }
await prisma.child.update({ 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: { data: {
firstName: parsed.data.firstName, firstName: parsed.data.firstName,
lastName: parsed.data.lastName, lastName: parsed.data.lastName,
@@ -271,15 +297,34 @@ export async function deleteMyChild(childId: string) {
try { try {
await serializableTransaction(async (tx) => { 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({ 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 }, select: { id: true },
}); });
if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt."); if (!child)
await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]); 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({ 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,35 +332,32 @@ export async function deleteMyChild(childId: string) {
revalidatePath("/dashboard/families"); revalidatePath("/dashboard/families");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error); if (!(error instanceof UserFacingError))
return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") }; 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(); const session = await requireKitaSession();
try { try {
const user = await prisma.user.findFirst({ return await serializableTransaction(async (tx) => {
where: { await lockKitaAccounts(tx, session.user.kitaId);
id: session.user.id, const user = await tx.user.findFirst({
kitaId: session.user.kitaId, where: { id: session.user.id, kitaId: session.user.kitaId },
}, select: { id: true, familyId: true },
select: {
id: true,
familyId: true,
},
}); });
if (!user) throw new UserFacingError("Account wurde nicht gefunden.");
if (!user) { await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]);
return { error: "Account wurde nicht gefunden." };
}
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const assignments = user.familyId
const activeNotdienstAssignments = user.familyId ? await tx.notdienstAssignment.count({
? await prisma.notdienstAssignment.count({
where: { where: {
kitaId: session.user.kitaId, kitaId: session.user.kitaId,
date: { gte: today }, date: { gte: today },
@@ -324,52 +366,33 @@ export async function deleteMyAccount() {
}, },
}) })
: 0; : 0;
if (assignments)
if (activeNotdienstAssignments > 0) { throw new UserFacingError(
return {
error:
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.", "Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
);
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 };
});
} catch (error) {
if (!(error instanceof UserFacingError))
console.error("Fehler beim Löschen des Accounts:", error);
return {
error: publicActionError(
error,
"Ein Fehler ist beim Löschen aufgetreten.",
),
}; };
} }
if (!user.familyId) {
await prisma.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." };
}
} }
+2 -1
View File
@@ -56,6 +56,7 @@ export default async function ProfilPage() {
city: true, city: true,
directoryOptInAt: true, directoryOptInAt: true,
staffDecisionEmails: true, staffDecisionEmails: true,
staffProfile: { select: { id: true } },
createdAt: true, createdAt: true,
family: { family: {
select: { select: {
@@ -210,7 +211,7 @@ export default async function ProfilPage() {
</CardContent> </CardContent>
</Card> </Card>
{user.role === UserRole.ERZIEHER && ( {user.staffProfile && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>E-Mail-Hinweise</CardTitle> <CardTitle>E-Mail-Hinweise</CardTitle>
@@ -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.",
),
};
}
}
+113
View File
@@ -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 (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
VERWALTUNG · KITA-KONTEN
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Zugriffsrechte{" "}
<em className="font-normal text-[var(--accent-deep)]">verwalten</em>
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
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.
</p>
</header>
<RoleList
currentUserId={user.id}
people={people.map((person) => ({
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",
}))}
/>
<Card className="mt-7 p-5">
<h2 className="text-lg font-semibold text-[var(--ink)]">
Letzte Rollenänderungen
</h2>
<p className="mt-1 text-sm leading-6 text-[var(--ink-soft)]">
Ämter und Zuständigkeiten verwaltest du weiterhin bei den Familien.
Sie verändern die Zugriffsrechte nicht.
</p>
{history.length ? (
<ul className="mt-4 divide-y divide-[var(--hairline-soft)]">
{history.map((entry) => {
const data = entry.metadata as {
previousRole?: UserRole;
newRole?: UserRole;
} | null;
return (
<li key={entry.id} className="py-3 text-sm leading-6">
<p className="font-medium text-[var(--ink)]">
{entry.targetLabel}:{" "}
{data?.previousRole
? kitaRoleLabels[data.previousRole]
: "—"}{" "}
→ {data?.newRole ? kitaRoleLabels[data.newRole] : "—"}
</p>
<p className="break-words text-xs text-[var(--ink-muted)]">
{new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Berlin",
}).format(entry.createdAt)}{" "}
· {entry.actorEmail}
</p>
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-[var(--ink-muted)]">
Noch keine Rollenänderungen protokolliert.
</p>
)}
</Card>
</div>
);
}
@@ -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<string, string> = {
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 (
<>
<Button
variant="outline"
size="sm"
aria-label={`Zugriffsrechte für ${person.name}`}
disabled={person.role === "SUPERADMIN"}
onClick={() => {
setRole(person.role);
setError("");
setOpen(true);
}}
>
<ShieldCheck className="h-3.5 w-3.5" /> Zugriffsrechte
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!pending) setOpen(next);
}}
>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Zugriffsrechte für {person.name}</DialogTitle>
<DialogDescription>
Die Zugriffsrolle bestimmt, welche Bereiche diese Person nutzen
und verwalten kann. Ämter wie „Vorstand“ werden separat vergeben.
</DialogDescription>
</DialogHeader>
<fieldset disabled={pending} className="grid gap-2">
<legend className="mb-2 text-sm font-semibold text-[var(--ink)]">
Zugriffsrolle
</legend>
{roles.map((value) => (
<label
key={value}
className={`flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors ${role === value ? "border-[var(--accent-mid)] bg-[var(--accent-soft)]" : "border-[var(--hairline)] hover:bg-[var(--surface-2)]"}`}
>
<input
type="radio"
name={`role-${person.id}`}
value={value}
checked={role === value}
onChange={() => setRole(value)}
className="mt-1 accent-[var(--accent-deep)]"
/>
<span>
<span className="block text-sm font-semibold text-[var(--ink)]">
{kitaRoleLabels[value]}
</span>
<span className="mt-1 block text-xs leading-5 text-[var(--ink-soft)]">
{descriptions[value]}
</span>
</span>
</label>
))}
</fieldset>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
{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."}
</p>
{ownAccount && role !== person.role ? (
<p className="rounded-lg bg-[var(--warn-soft)] p-3 text-sm leading-5 text-[var(--warn)]">
Du änderst deine eigenen Rechte. Nach dem Speichern gelangst du
zur Übersicht; der Zugang zur Verwaltung richtet sich dann nach
der neuen Rolle.
</p>
) : null}
{error ? (
<p role="alert" className="text-sm leading-5 text-[var(--danger)]">
{error}
</p>
) : null}
<DialogFooter>
<Button
variant="outline"
disabled={pending}
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button disabled={pending || role === person.role} onClick={submit}>
{pending ? "Wird gespeichert …" : "Rolle speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Input
type="search"
aria-label="Kita-Konten durchsuchen"
placeholder="Name, Haushalt oder Rolle suchen …"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="max-w-md"
/>
<span className="text-sm text-[var(--ink-muted)]">
{filtered.length} {filtered.length === 1 ? "Konto" : "Konten"}
</span>
</div>
<Card className="overflow-hidden p-0">
<div className="hidden gap-6 border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] px-5 py-3 text-[11px] font-semibold uppercase tracking-wider text-[var(--ink-muted)] md:grid md:grid-cols-[minmax(0,1fr)_220px_160px]">
<span>Person & Zuordnung</span>
<span>Zugriffsrolle</span>
<span className="text-right">Verwalten</span>
</div>
<div className="divide-y divide-[var(--hairline-soft)]">
{filtered.map((person) => (
<div
key={person.id}
className="grid items-center gap-4 px-5 py-5 md:grid-cols-[minmax(0,1fr)_220px_160px] md:gap-6"
>
<div className="min-w-0">
<p className="font-semibold text-[var(--ink)]">
{person.name}
{person.id === currentUserId ? (
<span className="ml-2 text-xs font-normal text-[var(--ink-muted)]">
Du
</span>
) : null}
</p>
<p className="mt-1 break-words text-sm text-[var(--ink-soft)]">
{person.email}
</p>
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">
{[
person.familyName
? `Haushalt ${person.familyName}`
: "Kein eigener Haushalt",
person.hasStaffProfile ? "Personalprofil vorhanden" : null,
person.status,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<RoleBadge role={person.role} />
<div className="md:justify-self-end">
<RoleChangeButton
person={person}
ownAccount={person.id === currentUserId}
/>
</div>
</div>
))}
{!filtered.length ? (
<p className="p-8 text-center text-sm text-[var(--ink-soft)]">
Keine passenden Kita-Konten gefunden.
</p>
) : null}
</div>
</Card>
</div>
);
}
+9
View File
@@ -14,6 +14,7 @@ import {
Megaphone, Megaphone,
Clock3, Clock3,
BellRing, BellRing,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { NavItem } from "@/components/ui/nav-item"; import { NavItem } from "@/components/ui/nav-item";
@@ -21,6 +22,14 @@ import { NavItem } from "@/components/ui/nav-item";
import { UserRole } from "@prisma/client"; import { UserRole } from "@prisma/client";
const navItems = [ const navItems = [
{
section: "Verwaltung",
href: "/dashboard/zugriffsrechte",
label: "Zugriffsrechte",
icon: ShieldCheck,
exact: true,
allowedRoles: [UserRole.ADMIN],
},
{ {
section: "Heute", section: "Heute",
href: "/dashboard/notdienst/alarm", href: "/dashboard/notdienst/alarm",
+3 -9
View File
@@ -1,5 +1,7 @@
import type { UserRole } from "@prisma/client"; import type { UserRole } from "@prisma/client";
import { kitaRoleLabels } from "@/lib/kita-roles";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type RoleBadgeProps = { type RoleBadgeProps = {
@@ -7,14 +9,6 @@ type RoleBadgeProps = {
className?: string; className?: string;
}; };
const roleLabels: Record<string, string> = {
SUPERADMIN: "Admin",
ADMIN: "Vorstand",
KOORDINATOR: "Koordinator",
ELTERN: "Eltern",
ERZIEHER: "Erzieher",
};
export function RoleBadge({ role, className }: RoleBadgeProps) { export function RoleBadge({ role, className }: RoleBadgeProps) {
return ( return (
<span <span
@@ -23,7 +17,7 @@ export function RoleBadge({ role, className }: RoleBadgeProps) {
className, className,
)} )}
> >
{roleLabels[role] ?? role} {kitaRoleLabels[role]}
</span> </span>
); );
} }
+18 -3
View File
@@ -39,14 +39,29 @@ export async function deliverAlarmEmails(
include: { user: true, alarm: { include: { kita: true } } }, include: { user: true, alarm: { include: { kita: true } } },
}); });
const { user, alarm } = job; 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); if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
else else
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId; eligible &&=
["ELTERN", "ADMIN", "KOORDINATOR"].includes(user.role) &&
user.familyId === alarm.familyId;
if (job.kind === "REQUEST") { if (job.kind === "REQUEST") {
const token = job.path.split("/").pop(); const token = job.path.split("/").pop();
const recipient = await prisma.notdienstAlarmRecipient.findFirst({ 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 &&= eligible &&=
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now; !!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
+52 -16
View File
@@ -114,22 +114,30 @@ async function queue(
const users = const users =
audience === "managers" audience === "managers"
? await tx.user.findMany({ ? 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({ : await tx.user.findMany({
where: { where: {
kitaId: alarm.kitaId, kitaId: alarm.kitaId,
familyId: alarm.familyId, familyId: alarm.familyId,
role: "ELTERN", role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null }, emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
alarmRecipients: { some: { alarmId: alarm.id } }, alarmRecipients: { some: { alarmId: alarm.id } },
}, },
}); });
const jobs: string[] = []; const jobs: string[] = [];
for (const user of users) { for (const user of users) {
const recipient = const recipient = kind === "REQUEST" ? requestTokens?.get(user.id) : null;
kind === "REQUEST" ? requestTokens?.get(user.id) : null; if (kind === "REQUEST" && !recipient)
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing"); throw new Error("Request token missing");
await tx.notdienstAlarmNotification.create({ await tx.notdienstAlarmNotification.create({
data: { alarmId: alarm.id, userId: user.id, eventKey, message }, data: { alarmId: alarm.id, userId: user.id, eventKey, message },
}); });
@@ -204,7 +212,15 @@ export async function executeAlarmCommand(
include: { include: {
family: { family: {
include: { 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) if (!assignment.kita.notdienstModuleEnabled)
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert."); throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
if (assignment.plan.status !== "PUBLISHED") 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); const date = dateKey(assignment.date);
if (date < todayKey(now)) 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); const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
if (expiresAt <= now) if (expiresAt <= now)
throw new UserFacingError("Dieser Einsatz ist bereits beendet."); 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.", "Für diese Einteilung besteht bereits ein aktiver Alarm.",
); );
const parents = assignment.child.family.users; 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) if (!parents.length)
throw new UserFacingError( throw new UserFacingError(
"Für diesen Haushalt sind keine Elternkonten hinterlegt.", "Für diesen Haushalt sind keine Elternkonten hinterlegt.",
@@ -353,7 +375,9 @@ export async function executeAlarmCommand(
); );
const reason = note.parse(data.reason); const reason = note.parse(data.reason);
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION") 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 = const approve =
command === "approveLate" || z.boolean().parse(data.approve); command === "approveLate" || z.boolean().parse(data.approve);
const updated = await tx.notdienstAlarm.update({ const updated = await tx.notdienstAlarm.update({
@@ -401,7 +425,12 @@ export async function executeAlarmCommand(
async function recipientForToken(tx: Tx, token: string) { async function recipientForToken(tx: Tx, token: string) {
const recipient = await tx.notdienstAlarmRecipient.findFirst({ 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: { include: {
user: true, user: true,
alarm: { include: { kita: { select: { name: true } } } }, alarm: { include: { kita: { select: { name: true } } } },
@@ -409,9 +438,11 @@ async function recipientForToken(tx: Tx, token: string) {
}); });
if ( if (
!recipient || !recipient ||
recipient.user.role !== "ELTERN" || !["ELTERN", "ADMIN", "KOORDINATOR"].includes(recipient.user.role) ||
recipient.sessionVersion !== recipient.user.sessionVersion || 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.alarm.expiresAt <= new Date() ||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) || (recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
recipient.user.kitaId !== recipient.alarm.kitaId || recipient.user.kitaId !== recipient.alarm.kitaId ||
@@ -423,7 +454,12 @@ async function recipientForToken(tx: Tx, token: string) {
export async function getPublicAlarm(token: string) { export async function getPublicAlarm(token: string) {
if (!tokenSchema.safeParse(token).success) return null; if (!tokenSchema.safeParse(token).success) return null;
const legacy = await prisma.notdienstAlert.findFirst({ 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 }, select: { alarmId: true, assignmentId: true },
}); });
if (legacy && !legacy.alarmId) if (legacy && !legacy.alarmId)
@@ -591,7 +627,7 @@ export async function importLegacyAlarms(
const old = assignment.alerts; const old = assignment.alerts;
const valid = old.filter( const valid = old.filter(
(a) => (a) =>
a.parentUser.role === "ELTERN" && ["ELTERN", "ADMIN", "KOORDINATOR"].includes(a.parentUser.role) &&
a.parentUser.familyId === assignment.child.familyId && a.parentUser.familyId === assignment.child.familyId &&
a.parentUser.kitaId === assignment.kitaId, a.parentUser.kitaId === assignment.kitaId,
); );
+41
View File
@@ -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.",
);
}
+116
View File
@@ -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,
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import type { UserRole } from "@prisma/client";
export const kitaRoleLabels: Record<UserRole, string> = {
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"];
}
+8 -4
View File
@@ -40,25 +40,29 @@ export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
}, },
}); });
const { user, profile } = job; 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) { if (job.inviteToken) {
const token = await prisma.verificationToken.findUnique({ const token = await prisma.verificationToken.findUnique({
where: { token: hashToken(job.inviteToken) }, where: { token: hashToken(job.inviteToken) },
}); });
eligible &&= eligible &&=
profile.userId === user.id && profile.userId === user.id &&
user.role === "ERZIEHER" && ["ERZIEHER", "ADMIN"].includes(user.role) &&
!user.emailVerifiedAt && !user.emailVerifiedAt &&
!!token && !!token &&
token.identifier === user.id && token.identifier === user.id &&
token.expires > now; token.expires > now;
} else { } else {
eligible &&= eligible &&=
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && !!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now) && (!user.lockedUntil || user.lockedUntil <= now) &&
(job.toAdmins (job.toAdmins
? user.role === "ADMIN" ? user.role === "ADMIN"
: user.role === "ERZIEHER" && : ["ERZIEHER", "ADMIN"].includes(user.role) &&
profile.userId === user.id && profile.userId === user.id &&
user.staffDecisionEmails); user.staffDecisionEmails);
} }
+46 -15
View File
@@ -81,7 +81,11 @@ async function inviteStaff(
if ( if (
existingUser && existingUser &&
(existingUser.kitaId !== actor.kitaId || (existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER || !(
existingUser.role === UserRole.ERZIEHER ||
(existingUser.role === UserRole.ADMIN &&
existingUser.id === profile.userId)
) ||
(await tx.staffProfile.count({ (await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profile.id } }, where: { userId: existingUser.id, id: { not: profile.id } },
}))) })))
@@ -205,7 +209,9 @@ export function assertStaffAccess(
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil."); throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
if (adminOnly) requireAdmin(actor); if (adminOnly) requireAdmin(actor);
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id) 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) { export async function getStaffProfile(actor: StaffActor, profileId?: string) {
@@ -419,7 +425,9 @@ export async function executeStaffCommand(
if ( if (
profile.user && profile.user &&
(profile.user.kitaId !== actor.kitaId || (profile.user.kitaId !== actor.kitaId ||
profile.user.role !== UserRole.ERZIEHER) !([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes(
profile.user.role,
))
) )
throw new UserFacingError( throw new UserFacingError(
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.", "Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
@@ -440,7 +448,11 @@ export async function executeStaffCommand(
return { ...invitation, educatorId: educator.id }; return { ...invitation, educatorId: educator.id };
} }
if (command === "emailPreference") { if (command === "emailPreference") {
if (actor.role !== UserRole.ERZIEHER) if (
!(await tx.staffProfile.count({
where: { userId: actor.id, kitaId: actor.kitaId },
}))
)
throw new UserFacingError( throw new UserFacingError(
"Diese Einstellung ist für Fachkräfte vorgesehen.", "Diese Einstellung ist für Fachkräfte vorgesehen.",
); );
@@ -534,7 +546,9 @@ export async function executeStaffCommand(
input.employmentEnd && input.employmentEnd &&
input.employmentEnd < input.employmentStart 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 ( if (
input.trackingStart < input.employmentStart || input.trackingStart < input.employmentStart ||
(input.employmentEnd && input.trackingStart > input.employmentEnd) (input.employmentEnd && input.trackingStart > input.employmentEnd)
@@ -545,7 +559,8 @@ export async function executeStaffCommand(
const educator = await tx.educator.findFirst({ const educator = await tx.educator.findFirst({
where: { id: input.educatorId, kitaId: actor.kitaId }, 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({ const existing = await tx.staffProfile.findFirst({
where: { educatorId: educator.id, kitaId: actor.kitaId }, where: { educatorId: educator.id, kitaId: actor.kitaId },
include: profileInclude, include: profileInclude,
@@ -591,7 +606,8 @@ export async function executeStaffCommand(
where: { id: profileId, kitaId: actor.kitaId }, where: { id: profileId, kitaId: actor.kitaId },
include: profileInclude, include: profileInclude,
}); });
if (!profile) throw new UserFacingError("Personalprofil nicht gefunden."); if (!profile)
throw new UserFacingError("Personalprofil nicht gefunden.");
assertStaffAccess(actor, profile); assertStaffAccess(actor, profile);
const holidays = new Set( const holidays = new Set(
( (
@@ -631,7 +647,10 @@ export async function executeStaffCommand(
for (const user of recipients) { for (const user of recipients) {
if ( if (
!toAdmins && !toAdmins &&
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails) (!(
[UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]
).includes(user.role) ||
!user.staffDecisionEmails)
) )
continue; continue;
const job = await tx.staffEmail.create({ const job = await tx.staffEmail.create({
@@ -719,7 +738,9 @@ export async function executeStaffCommand(
input.employmentEnd && input.employmentEnd &&
input.employmentEnd < dateKey(profile.employmentStart) 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 ( if (
input.employmentEnd && input.employmentEnd &&
(input.employmentEnd < dateKey(profile.trackingStart) || (input.employmentEnd < dateKey(profile.trackingStart) ||
@@ -786,7 +807,9 @@ export async function executeStaffCommand(
(profile.employmentEnd && (profile.employmentEnd &&
input.date > dateKey(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) if (input.remove)
await tx.staffDayOverride.deleteMany({ await tx.staffDayOverride.deleteMany({
where: { profileId, date: dateOnly(input.date) }, where: { profileId, date: dateOnly(input.date) },
@@ -859,7 +882,9 @@ export async function executeStaffCommand(
}) })
.parse(data); .parse(data);
if (input.date < dateKey(profile.trackingStart)) 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({ await tx.staffAdjustment.create({
data: { data: {
profileId, profileId,
@@ -887,7 +912,8 @@ export async function executeStaffCommand(
throw new UserFacingError("Zeiteintrag nicht gefunden."); throw new UserFacingError("Zeiteintrag nicht gefunden.");
if (existing) ensureDayEditable(profile, dateKey(existing.date)); if (existing) ensureDayEditable(profile, dateKey(existing.date));
if (command === "deleteTime") { 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 tx.staffTimeEntry.delete({ where: { id: existing.id } });
await history( await history(
"Arbeitszeit gelöscht", "Arbeitszeit gelöscht",
@@ -1135,7 +1161,8 @@ export async function executeStaffCommand(
const existing = data.id const existing = data.id
? profile.leaves.find((l) => l.id === idSchema.parse(data.id)) ? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
: undefined; : 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 (command === "leaveCancel") {
if ( if (
!existing || !existing ||
@@ -1182,7 +1209,9 @@ export async function executeStaffCommand(
!existing || !existing ||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status) !["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"; const cancelling = existing.status === "CANCEL_REQUESTED";
if (input.approve) if (input.approve)
ensureRangeEditable( ensureRangeEditable(
@@ -1284,7 +1313,9 @@ export async function executeStaffCommand(
.parse(data); .parse(data);
if (input.kind === "SICKNESS") requireAdmin(actor); if (input.kind === "SICKNESS") requireAdmin(actor);
if (existing && existing.status !== "PENDING") 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)) if (input.startDate < dateKey(profile.trackingStart))
throw new UserFacingError( throw new UserFacingError(
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.", "Abwesenheiten bitte ab Erfassungsbeginn erfassen.",
+599
View File
@@ -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)));
});
+139
View File
@@ -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, /<select[^>]*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/);
});