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
+91 -54
View File
@@ -7,6 +7,12 @@ import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { serializableTransaction } from "@/lib/transaction";
import { UserFacingError } from "@/lib/action-error";
import { prisma } from "@/lib/prisma";
const deleteKitaSchema = z.object({
@@ -28,7 +34,8 @@ type ActionResult = {
async function requireSuperadminActor() {
const session = await requireRole([UserRole.SUPERADMIN]);
if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin");
if (Date.now() - session.user.authenticatedAt > 15 * 60_000)
redirect("/login?reauth=1&callbackUrl=/admin");
return {
id: session.user.id,
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
@@ -96,7 +103,10 @@ export async function deleteKita(
const counts = await getKitaDeleteCounts(kita.id);
const attachmentIds = await prisma.$transaction(async (tx) => {
const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } });
const attachments = await tx.attachment.findMany({
where: { announcement: { kitaId: kita.id } },
select: { id: true },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
@@ -111,7 +121,7 @@ export async function deleteKita(
});
await tx.kita.delete({ where: { id: kita.id } });
return attachments.map(item => item.id);
return attachments.map((item) => item.id);
});
await removeAttachmentFiles(attachmentIds);
@@ -156,13 +166,19 @@ async function findMutableTenantUser(userId: string, actorId: string) {
return { error: "Benutzer wurde nicht gefunden." } as const;
}
if (user.id === actorId) {
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
return {
error: "Das eigene Superadmin-Konto kann hier nicht geändert werden.",
} as const;
}
if (user.role === UserRole.SUPERADMIN) {
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
return {
error: "Andere Superadmins werden in V1 nicht verwaltet.",
} as const;
}
if (!user.kitaId) {
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
return {
error: "Nur tenant-gebundene Benutzer können verwaltet werden.",
} as const;
}
return { user } as const;
@@ -188,34 +204,44 @@ export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
};
}
const attachmentIds = await prisma.$transaction(async (tx) => {
const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } });
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "USER_DELETE",
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
kitaName: user.kita?.name ?? null,
kitaSlug: user.kita?.slug ?? null,
familyId: user.familyId,
familyName: user.family?.name ?? null,
familyUserCountBeforeDelete: user.family?._count.users ?? null,
let attachmentIds: string[];
try {
attachmentIds = await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, user.kitaId!);
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
const attachments = await tx.attachment.findMany({
where: { announcement: { authorId: user.id } },
select: { id: true },
});
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "USER_DELETE",
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
kitaName: user.kita?.name ?? null,
kitaSlug: user.kita?.slug ?? null,
familyId: user.familyId,
familyName: user.family?.name ?? null,
familyUserCountBeforeDelete: user.family?._count.users ?? null,
},
},
},
});
await tx.user.delete({ where: { id: user.id } });
return attachments.map((item) => item.id);
});
await tx.user.delete({ where: { id: user.id } });
return attachments.map(item => item.id);
});
} catch (error) {
if (!(error instanceof UserFacingError)) throw error;
return { ok: false, error: error.message };
}
await removeAttachmentFiles(attachmentIds);
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
@@ -245,29 +271,41 @@ async function updateUserSupportState(
const { user } = lookup;
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action,
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
previousFailedLoginAttempts: user.failedLoginAttempts,
try {
await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, user.kitaId!);
if (action === "USER_LOCK")
await assertKitaAdminRemains(tx, user.kitaId!, [user.id]);
await tx.user.update({
where: { id: user.id },
data: {
...data,
...(action === "USER_LOCK"
? { sessionVersion: { increment: 1 } }
: {}),
},
},
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action,
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
previousFailedLoginAttempts: user.failedLoginAttempts,
},
},
});
});
});
} catch (error) {
if (!(error instanceof UserFacingError)) throw error;
return { ok: false, error: error.message };
}
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
@@ -296,4 +334,3 @@ export async function resetFailedLoginAttempts(
failedLoginAttempts: 0,
});
}
@@ -1,5 +1,9 @@
"use client";
import {
RoleChangeButton,
type RolePerson,
} from "../../zugriffsrechte/role-change-button";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -47,6 +51,7 @@ type EducatorDto = {
email: string | null;
verified: boolean;
inviteFailed: boolean;
account: RolePerson | null;
};
function AccessFields({
@@ -119,9 +124,11 @@ function AccessFields({
}
export function ErzieherList({
currentUserId,
educators,
today,
}: {
currentUserId: string;
educators: EducatorDto[];
today: string;
}) {
@@ -377,6 +384,14 @@ export function ErzieherList({
</Badge>
</TableCell>
<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
variant="outline"
size="sm"
+19 -1
View File
@@ -21,7 +21,15 @@ export default async function ErzieherPage() {
staffProfile: {
select: {
id: true,
user: { select: { email: true, emailVerifiedAt: true } },
user: {
select: {
id: true,
role: true,
familyId: true,
email: true,
emailVerifiedAt: true,
},
},
emails: {
where: { inviteToken: { not: null }, skippedAt: null },
orderBy: { createdAt: "desc" },
@@ -65,10 +73,20 @@ export default async function ErzieherPage() {
</header>
<ErzieherList
currentUserId={session.user.id}
today={todayKey()}
educators={educators.map(({ staffProfile, ...educator }) => ({
...educator,
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,
verified: !!staffProfile?.user?.emailVerifiedAt,
inviteFailed: !!staffProfile?.emails[0]?.failed,
@@ -1,5 +1,8 @@
"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 { LayoutGrid, Mail, Search, Table, Users } from "lucide-react";
import { toast } from "sonner";
@@ -29,6 +32,8 @@ interface FamilyUser {
email: string;
emailVerifiedAt: Date | null;
invitation: FamilyInvitationView;
role: UserRole;
staffProfile: { id: string } | null;
dutyAssignments: {
id: string;
dutyId: string;
@@ -60,12 +65,14 @@ interface DutyData {
}
interface FamiliesListClientProps {
currentUserId: string;
initialFamilies: FamilyData[];
allDuties: DutyData[];
canManageDuties: boolean;
}
export function FamiliesListClient({
currentUserId,
initialFamilies,
allDuties,
canManageDuties,
@@ -85,7 +92,7 @@ export function FamiliesListClient({
(user) =>
user.firstName.toLowerCase().includes(q) ||
user.lastName.toLowerCase().includes(q) ||
user.email.toLowerCase().includes(q)
user.email.toLowerCase().includes(q),
);
if (matchesParent) return true;
@@ -93,20 +100,20 @@ export function FamiliesListClient({
const matchesChild = family.children.some(
(child) =>
child.firstName.toLowerCase().includes(q) ||
child.lastName.toLowerCase().includes(q)
child.lastName.toLowerCase().includes(q),
);
return matchesChild;
});
const childCount = filteredFamilies.reduce(
(acc, family) => acc + family.children.length,
0
0,
);
const pendingInviteCount = filteredFamilies.reduce(
(acc, family) =>
acc + family.users.filter((user) => !user.emailVerifiedAt).length,
0
0,
);
return (
@@ -123,14 +130,17 @@ export function FamiliesListClient({
</em>
</h1>
<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>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setViewMode(viewMode === "table" ? "cards" : "table")}
onClick={() =>
setViewMode(viewMode === "table" ? "cards" : "table")
}
className="flex items-center gap-1.5"
>
{viewMode === "table" ? (
@@ -152,7 +162,9 @@ export function FamiliesListClient({
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<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="text-[var(--ink)] tabular-nums">{filteredFamilies.length}</span>
<span className="text-[var(--ink)] tabular-nums">
{filteredFamilies.length}
</span>
Familien
</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)]">
@@ -160,7 +172,9 @@ export function FamiliesListClient({
Kinder
</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="text-[var(--ink)] tabular-nums">{pendingInviteCount}</span>
<span className="text-[var(--ink)] tabular-nums">
{pendingInviteCount}
</span>
Offene Einladungen
</span>
</div>
@@ -196,7 +210,8 @@ export function FamiliesListClient({
<div>
<CardTitle>Haushalte</CardTitle>
<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
</p>
</div>
@@ -218,7 +233,7 @@ export function FamiliesListClient({
<TableBody>
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
(user) => !user.emailVerifiedAt,
);
return (
@@ -250,10 +265,29 @@ export function FamiliesListClient({
<Mail className="h-3 w-3 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
{!parent.emailVerifiedAt && (
<ResendInviteButton userId={parent.id} />
)}
</span>
</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>
@@ -277,7 +311,9 @@ export function FamiliesListClient({
)}
</TableCell>
<TableCell>
<Badge variant={hasPendingInvite ? "warning" : "success"}>
<Badge
variant={hasPendingInvite ? "warning" : "success"}
>
{hasPendingInvite ? "Einladung offen" : "Aktiv"}
</Badge>
</TableCell>
@@ -323,7 +359,7 @@ export function FamiliesListClient({
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredFamilies.map((family) => {
const hasPendingInvite = family.users.some(
(user) => !user.emailVerifiedAt
(user) => !user.emailVerifiedAt,
);
return (
<Card key={family.id} className="flex flex-col h-full">
@@ -333,9 +369,12 @@ export function FamiliesListClient({
{family.name.slice(0, 1)}
</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)]">
{family.users.length} Elternteil{family.users.length === 1 ? "" : "e"}
{family.users.length} Elternteil
{family.users.length === 1 ? "" : "e"}
</p>
</div>
</div>
@@ -359,10 +398,29 @@ export function FamiliesListClient({
<Mail className="h-3.5 w-3.5 shrink-0 [stroke-width:1.7]" />
<span className="break-words [overflow-wrap:anywhere]">
{parent.email}
{!parent.emailVerifiedAt && <ResendInviteButton userId={parent.id} />}
{!parent.emailVerifiedAt && (
<ResendInviteButton userId={parent.id} />
)}
</span>
</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>
@@ -430,8 +488,21 @@ export function FamiliesListClient({
function ResendInviteButton({ userId }: { userId: string }) {
const [pending, startTransition] = useTransition();
return <Button type="button" size="sm" variant="ghost" disabled={pending} onClick={() => startTransition(async () => {
const result = await resendParentInvite(userId);
if (result.error) toast.error(result.error); else toast.success("Einladung erneut gesendet.");
})}>{pending ? "Wird gesendet…" : "Einladung erneut senden"}</Button>;
return (
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await resendParentInvite(userId);
if (result.error) toast.error(result.error);
else toast.success("Einladung erneut gesendet.");
})
}
>
{pending ? "Wird gesendet…" : "Einladung erneut senden"}
</Button>
);
}
+156 -77
View File
@@ -11,6 +11,10 @@ import {
queueFamilyInvitation,
resendFamilyInvitation,
} from "@/lib/family-invitations";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error";
@@ -149,7 +153,12 @@ export async function addFamilyAction(
return { errors: { _form: ["Kein Mandant zugeordnet."] } };
}
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } };
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
return {
errors: {
_form: ["Zu viele Einladungen. Bitte versuche es später erneut."],
},
};
const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData));
if (!parsedFamily.success) {
@@ -310,8 +319,11 @@ export async function updateFamilyAction(
}
const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." };
const activeChildren = children.filter(
(child) => !removedChildIdSet.has(child.id),
);
if (activeChildren.length + newChildren.length > 20)
return { error: "Maximal 20 Kinder pro Familie." };
if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
@@ -341,13 +353,26 @@ export async function updateFamilyAction(
return { error: "Familie wurde nicht gefunden." };
}
const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email);
const needsEmail =
newParents.length > 0 ||
parents.some(
(parent) =>
existingFamily.users.find((user) => user.id === parent.id)?.email !==
parent.email,
);
if (needsEmail) {
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." };
if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed)
return {
error: "Zu viele Einladungen. Bitte versuche es später erneut.",
};
}
const existingParentIds = new Set(existingFamily.users.map((user) => user.id));
const existingChildIds = new Set(existingFamily.children.map((child) => child.id));
const existingParentIds = new Set(
existingFamily.users.map((user) => user.id),
);
const existingChildIds = new Set(
existingFamily.children.map((child) => child.id),
);
if (!parents.every((parent) => existingParentIds.has(parent.id))) {
return {
@@ -367,79 +392,109 @@ export async function updateFamilyAction(
return { error: "Pro Familie sind maximal zwei Elternteile vorgesehen." };
}
await prisma.$transaction(async (tx) => {
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
const removed = await tx.child.count({ where: { kitaId, familyId, id: { in: removedChildIds } } });
if (removed !== removedChildIds.length) throw new UserFacingError("Mindestens ein Kind gehört nicht zu dieser Familie.");
await tx.family.update({
where: { id: familyId },
data: { name: familyName },
});
for (const parent of parents) {
const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } });
if (!current) throw new Error("Parent assignment changed");
const emailChanged = current.email !== parent.email;
await tx.user.update({
where: { id: parent.id, kitaId, familyId },
data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email,
...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) },
await prisma.$transaction(
async (tx) => {
await lockKitaAccounts(tx, kitaId);
const changedEmails = parents
.filter(
(parent) =>
existingFamily.users.find((user) => user.id === parent.id)
?.email !== parent.email,
)
.map((parent) => parent.id);
await assertKitaAdminRemains(tx, kitaId, changedEmails);
await assertChildrenRemovable(tx, kitaId, familyId, removedChildIds);
const removed = await tx.child.count({
where: { kitaId, familyId, id: { in: removedChildIds } },
});
if (emailChanged) {
if (removed !== removedChildIds.length)
throw new UserFacingError(
"Mindestens ein Kind gehört nicht zu dieser Familie.",
);
await tx.family.update({
where: { id: familyId },
data: { name: familyName },
});
for (const parent of parents) {
const current = await tx.user.findFirst({
where: { id: parent.id, kitaId, familyId },
select: { email: true },
});
if (!current) throw new Error("Parent assignment changed");
const emailChanged = current.email !== parent.email;
await tx.user.update({
where: { id: parent.id, kitaId, familyId },
data: {
firstName: parent.firstName,
lastName: parent.lastName,
email: parent.email,
...(emailChanged
? {
passwordHash: "",
emailVerifiedAt: null,
sessionVersion: { increment: 1 },
}
: {}),
},
});
if (emailChanged) {
const invite = await queueFamilyInvitation(
tx,
parent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
}
for (const parent of newParents) {
const createdParent = await tx.user.create({
data: {
kitaId,
familyId,
email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
},
});
const invite = await queueFamilyInvitation(
tx,
parent.id,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
}
for (const parent of newParents) {
const createdParent = await tx.user.create({
data: {
kitaId,
familyId,
email: parent.email,
firstName: parent.firstName,
lastName: parent.lastName,
passwordHash: "",
role: UserRole.ELTERN,
},
});
const invite = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
for (const child of activeChildren) {
await tx.child.update({
where: { id: child.id, kitaId, familyId },
data: {
firstName: child.firstName,
lastName: child.lastName,
},
});
}
for (const child of activeChildren) {
await tx.child.update({
where: { id: child.id, kitaId, familyId },
data: {
firstName: child.firstName,
lastName: child.lastName,
},
});
}
for (const childId of removedChildIds) {
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
}
for (const childId of removedChildIds) {
await tx.child.delete({ where: { id: childId, kitaId, familyId } });
}
for (const child of newChildren) {
await tx.child.create({
data: {
kitaId,
familyId,
firstName: child.firstName,
lastName: child.lastName,
},
});
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
for (const child of newChildren) {
await tx.child.create({
data: {
kitaId,
familyId,
firstName: child.firstName,
lastName: child.lastName,
},
});
}
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
@@ -448,8 +503,14 @@ export async function updateFamilyAction(
return { error: "Mindestens eine E-Mail-Adresse existiert bereits." };
}
if (!(error instanceof UserFacingError)) console.error("Fehler beim Aktualisieren der Familie:", error);
return { error: publicActionError(error, "Familie konnte nicht aktualisiert werden.") };
if (!(error instanceof UserFacingError))
console.error("Fehler beim Aktualisieren der Familie:", error);
return {
error: publicActionError(
error,
"Familie konnte nicht aktualisiert werden.",
),
};
}
const warning = await sendInviteJobs(inviteJobs);
@@ -464,15 +525,33 @@ export async function resendFamilyInvitationAction(
userId: string,
): Promise<{ success?: boolean; error?: string; warning?: string }> {
const session = await requireRole([UserRole.ADMIN]);
if (!session.user.kitaId || typeof userId !== "string" || !userId || userId.length > 64)
if (
!session.user.kitaId ||
typeof userId !== "string" ||
!userId ||
userId.length > 64
)
return { error: "Ungültiges Elternkonto." };
if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed)
if (
!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600))
.allowed
)
return { error: "Bitte versuche es später erneut." };
if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed)
if (
!(
await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)
).allowed
)
return { error: "Bitte versuche es später erneut." };
const recipient = await prisma.user.findFirst({
where: { id: userId, kitaId: session.user.kitaId, familyId: { not: null },
emailVerifiedAt: null, passwordHash: "", disabledAt: null },
where: {
id: userId,
kitaId: session.user.kitaId,
familyId: { not: null },
emailVerifiedAt: null,
passwordHash: "",
disabledAt: null,
},
select: { email: true },
});
if (!recipient) return { error: "Keine offene Einladung gefunden." };
+14 -2
View File
@@ -32,9 +32,17 @@ export default async function FamiliesPage() {
firstName: true,
lastName: true,
email: true,
role: true,
staffProfile: { select: { id: true } },
emailVerifiedAt: true,
familyInvitation: {
select: { expiresAt: true, sentAt: true, failed: true, leaseUntil: true, status: true },
select: {
expiresAt: true,
sentAt: true,
failed: true,
leaseUntil: true,
status: true,
},
},
dutyAssignments: {
select: {
@@ -77,11 +85,15 @@ export default async function FamiliesPage() {
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<FamiliesListClient
currentUserId={session.user.id}
initialFamilies={families.map((family) => ({
...family,
users: family.users.map(({ familyInvitation, ...parent }) => ({
...parent,
invitation: familyInvitationView(parent.emailVerifiedAt, familyInvitation),
invitation: familyInvitationView(
parent.emailVerifiedAt,
familyInvitation,
),
})),
}))}
allDuties={allDuties}
+157 -35
View File
@@ -15,7 +15,11 @@ import { getTargetMonthData } from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { todayKey } from "@/lib/alarms/format";
import { serializableTransaction } from "@/lib/transaction";
import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
import {
queueNotdienstPublication,
queueNotdienstReminders,
deliverDueNotdienstEmails,
} from "@/lib/notdienst-notifications";
import { sendAppEmail } from "@/lib/mail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth";
@@ -43,14 +47,20 @@ export async function generatePlanAction(year?: number, month?: number) {
targetMonth = data.targetMonth;
}
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
if (!validPlanMonth(targetYear, targetMonth))
return { error: "Ungültiger Planungsmonat." };
try {
await serializableTransaction(async (tx) => {
const existingPlan = await tx.notdienstPlan.findUnique({
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
});
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
if (existingPlan)
throw new UserFacingError(
"Ein Plan für diesen Monat existiert bereits.",
);
const [availabilities, workingDays] = await Promise.all([
tx.notdienstAvailability.findMany({
@@ -69,16 +79,29 @@ export async function generatePlanAction(year?: number, month?: number) {
const familyUsageCount = new Map<string, number>();
const assignments: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
const candidates = availabilities.filter(
(a) => a.date.getTime() === day.getTime(),
);
if (!candidates.length) continue;
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
const minUsage = Math.min(
...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0),
);
const leastUsed = candidates.filter(
(a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage,
);
const selected =
leastUsed[Math.floor(Math.random() * leastUsed.length)];
assignments.push({ childId: selected.childId, date: day });
familyUsageCount.set(selected.child.familyId, minUsage + 1);
}
const plan = await tx.notdienstPlan.create({
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
data: {
kitaId,
year: targetYear,
month: targetMonth,
status: NotdienstPlanStatus.DRAFT,
createdById: session.user.id,
},
});
if (assignments.length) {
await tx.notdienstAssignment.createMany({
@@ -91,7 +114,9 @@ export async function generatePlanAction(year?: number, month?: number) {
return { success: true };
} catch (error) {
console.error(error);
return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
return {
error: publicActionError(error, "Fehler bei der Generierung des Plans."),
};
}
}
@@ -103,30 +128,55 @@ export async function publishPlanAction(planId: string) {
await serializableTransaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
include: { assignments: { include: { child: { select: { active: true } } } } },
include: {
assignments: { include: { child: { select: { active: true } } } },
},
});
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
if (plan.status !== NotdienstPlanStatus.DRAFT)
throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
const validDates = new Set(days.map((day) => day.getTime()));
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
if (
plan.assignments.some(
(a) => !validDates.has(a.date.getTime()) || !a.child.active,
)
) {
throw new UserFacingError(
"Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.",
);
}
await tx.notdienstPlan.update({
where: { id: planId, kitaId },
data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
data: {
status: NotdienstPlanStatus.PUBLISHED,
publishedAt: new Date(),
},
});
await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
await queueNotdienstPublication(
tx,
kitaId,
planId,
plan.year,
plan.month,
);
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard/kalender");
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(
() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }),
);
revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery };
} catch (error) {
return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
return {
error: publicActionError(
error,
"Der Plan konnte nicht veröffentlicht werden.",
),
};
}
}
@@ -138,7 +188,8 @@ export async function updateAssignmentAction(
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
if (!dateOnlySchema.safeParse(date).success)
return { error: "Ungültiges Datum." };
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
@@ -161,14 +212,27 @@ export async function updateAssignmentAction(
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
if (
newChildId &&
(parsedDate.getUTCFullYear() !== plan.year ||
parsedDate.getUTCMonth() + 1 !== plan.month)
) {
throw new UserFacingError(
"Das Datum gehört nicht zum Planungsmonat.",
);
}
// Clearing obsolete entries remains possible after a new closure.
if (newChildId) {
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
const days = await loadNotdienstDays(
tx,
kitaId,
plan.year,
plan.month,
);
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
throw new UserFacingError(
"An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.",
);
}
}
@@ -189,7 +253,18 @@ export async function updateAssignmentAction(
family: {
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: {
role: {
in: [
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
],
},
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
select: { email: true, firstName: true, lastName: true },
},
},
@@ -217,7 +292,18 @@ export async function updateAssignmentAction(
family: {
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: {
role: {
in: [
UserRole.ELTERN,
UserRole.ADMIN,
UserRole.KOORDINATOR,
],
},
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
select: { email: true, firstName: true, lastName: true },
},
},
@@ -392,7 +478,15 @@ export async function updateKitaNotdienstSettingsAction(
return { error: "Kein Mandant zugeordnet." };
}
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
if (
!Number.isInteger(minPerChild) ||
minPerChild < 1 ||
minPerChild > 31 ||
!Number.isInteger(reminderDays) ||
reminderDays < 0 ||
reminderDays > 31
)
return { error: "Ungültige Einstellungen." };
try {
await prisma.kita.update({
@@ -411,33 +505,61 @@ export async function updateKitaNotdienstSettingsAction(
}
}
export async function sendNotdienstRemindersAction(year: number, month: number) {
export async function sendNotdienstRemindersAction(
year: number,
month: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
if (!validPlanMonth(year, month))
return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed)
return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
try {
const queued = await queueNotdienstReminders(kitaId, year, month);
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
const delivery = await deliverDueNotdienstEmails({
kitaId,
year,
month,
}).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
revalidatePath("/dashboard/notdienst/plan");
return { success: true, queued, count: delivery.sent, ...delivery };
} catch (error) {
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
return {
error: publicActionError(
error,
"Die Erinnerungen konnten nicht vorbereitet werden.",
),
};
}
}
export async function retryNotdienstEmailsAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
if (!validPlanMonth(year, month))
return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed)
return { error: "Bitte warte vor einem weiteren Versandversuch." };
try {
await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
await prisma.notdienstEmail.updateMany({
where: {
kitaId,
year,
month,
failed: true,
sentAt: null,
skippedAt: null,
},
data: { nextAttemptAt: new Date() },
});
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery };
} catch {
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
return {
error: "Der Versand bleibt gespeichert und wird erneut versucht.",
};
}
}
+57 -30
View File
@@ -4,7 +4,11 @@ import { de } from "date-fns/locale";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
import {
formatUtcDateKey,
getWorkingDaysOfMonth,
getTargetMonthData,
} from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
import { EmailStatusCard } from "./_components/email-status-card";
@@ -108,8 +112,6 @@ export default async function PlanungsZentralePage({
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true },
select: {
@@ -146,8 +148,11 @@ export default async function PlanungsZentralePage({
});
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
const availabilities = rawAvailabilities.filter((a) =>
validDateKeys.has(formatUtcDateKey(a.date)),
);
const closureCount =
getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => {
@@ -186,37 +191,52 @@ export default async function PlanungsZentralePage({
// Wir mappen die Assignments auf die Werktage
// Keep obsolete assignments visible so the coordinator can remove them.
const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
const displayedDays = new Map(
workingDays.map((day) => [formatUtcDateKey(day), day]),
);
for (const assignment of plan?.assignments ?? []) {
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
}
const activeChildIds = new Set(allChildren.map((child) => child.id));
const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime(),
);
const isOriginallyUnassigned =
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
const daysWithAssignments = Array.from(displayedDays.values())
.sort((a, b) => a.getTime() - b.getTime())
.map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime(),
);
const isOriginallyUnassigned =
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
return {
date: day.toISOString(),
isInvalid,
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
!!assignment && !isInvalid &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
const isInvalid =
!validDateKeys.has(formatUtcDateKey(day)) ||
(!!assignment && !activeChildIds.has(assignment.childId));
return {
date: day.toISOString(),
isInvalid,
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
!!assignment &&
!isInvalid &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
const mailStatus = await getNotdienstEmailStatus(
kitaId,
targetYear,
targetMonth,
);
const target = getTargetMonthData(now);
const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
const remindersOpen =
!target.isLocked &&
target.targetYear === targetYear &&
target.targetMonth === targetMonth &&
plan?.status !== "PUBLISHED";
return (
<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>
</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
planId={plan?.id}
+146 -41
View File
@@ -121,8 +121,14 @@ export function PlanView({
if ("error" in result && result.error) {
toast.error(result.error as string);
} 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.");
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
if (result.failed || ("deferred" in result && result.deferred))
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) {
toast.error(result.error as string);
} 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.`);
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
if (result.failed || ("deferred" in result && result.deferred))
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;
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) {
toast.error(result.error as string);
} else {
@@ -248,7 +264,10 @@ export function PlanView({
{family.childCount === 1 ? "" : "er"}
</p>
</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">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
@@ -318,8 +337,8 @@ export function PlanView({
Plan automatisch generieren
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
die verfügbaren Familien.
Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren
Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
@@ -412,6 +431,7 @@ export function PlanView({
<ManualAssignmentTable
days={days}
allChildren={allChildren}
familyStatuses={familyStatuses}
isDraft={isDraft}
isPending={isPending}
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) {
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)]">
@@ -461,7 +487,13 @@ function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number
}
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
</span>
);
@@ -552,7 +584,11 @@ function PlanPreviewTable({
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
{assignedChild
? assignedChild.parentName
: day.childId
? "Inaktives Kind"
: "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
@@ -576,8 +612,20 @@ function PlanPreviewTable({
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
<Badge
variant={
day.isInvalid
? "warning"
: assignedChild
? "success"
: "destructive"
}
>
{day.isInvalid
? "Bitte korrigieren"
: assignedChild
? "Besetzt"
: "Offen"}
</Badge>
</div>
</div>
@@ -588,17 +636,29 @@ function PlanPreviewTable({
);
}
function ManualAssignmentTable({
export function ManualAssignmentTable({
days,
allChildren,
familyStatuses,
isDraft,
isPending,
onAssignmentChange,
}: Pick<PlanViewProps, "days" | "allChildren"> & {
}: Pick<PlanViewProps, "days" | "allChildren" | "familyStatuses"> & {
isDraft: boolean;
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
const availabilityByFamily = useMemo(
() =>
new Map(
familyStatuses.map((family) => [
family.id,
new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))),
]),
),
[familyStatuses],
);
// Map childId to familyId for quick lookup
const childFamilyMap = useMemo(() => {
const map = new Map<string, string>();
@@ -626,7 +686,12 @@ function ManualAssignmentTable({
return (
<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>Eingeteilte Familie</div>
</div>
@@ -634,42 +699,82 @@ function ManualAssignmentTable({
{days.map((day) => {
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy);
const dateKey = day.date.slice(0, 10);
const available = allChildren.filter((child) =>
availabilityByFamily.get(child.familyId)?.has(dateKey),
);
const unavailable = allChildren.filter(
(child) => !availabilityByFamily.get(child.familyId)?.has(dateKey),
);
const availableFamilies = new Set(
available.map((child) => child.familyId),
).size;
const selectedChild = allChildren.find(
(child) => child.id === day.childId,
);
const selectedAvailable =
!!selectedChild &&
availabilityByFamily.get(selectedChild.familyId)?.has(dateKey);
const option = (child: PlanViewProps["allChildren"][number]) => (
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) —{" "}
{familyAssignmentCounts.get(child.familyId) ?? 0}x
</option>
);
return (
<div
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)]">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div className="flex flex-wrap items-center gap-2">
<select
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"}
disabled={isPending || !!day.activeAlarmId}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
) : null}
{allChildren.map((child) => {
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
<div className="w-full max-w-sm space-y-2">
<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)]"
value={day.childId || "empty"}
disabled={isPending || !!day.activeAlarmId}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{day.childId &&
!allChildren.some((child) => child.id === day.childId) ? (
<option value={day.childId} disabled>
Inaktives Kind – bitte Einteilung entfernen
</option>
);
})}
</select>
) : 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>
{!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 ? (
<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}
{!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">
+114 -91
View File
@@ -11,6 +11,10 @@ import {
parseDateInput,
} from "@/lib/child-birthdate";
import { serializableTransaction } from "@/lib/transaction";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { prisma } from "@/lib/prisma";
import { assertChildrenRemovable } from "@/lib/notdienst-planning";
import { UserFacingError, publicActionError } from "@/lib/action-error";
@@ -59,7 +63,11 @@ const childSchema = z
});
const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
familyName: z
.string()
.min(1, "Familienname ist erforderlich.")
.max(120)
.trim(),
});
const contactSchema = z.object({
@@ -88,10 +96,7 @@ const contactSchema = z.object({
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage(
error: z.ZodError,
fallback: string,
): string {
function firstZodMessage(error: z.ZodError, fallback: string): string {
const issue = error.issues[0];
return issue?.message || fallback;
}
@@ -121,8 +126,13 @@ export async function createMyChild(rawPayload: unknown) {
}
try {
await serializableTransaction(async tx => {
const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } });
await serializableTransaction(async (tx) => {
const count = await tx.child.count({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId!,
},
});
if (count >= 20) throw new Error("Household child limit reached");
await tx.child.create({
data: {
@@ -166,10 +176,17 @@ export async function createMyFamily(rawPayload: unknown) {
});
const attached = await tx.user.updateMany({
where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion },
where: {
id: session.user.id,
kitaId: session.user.kitaId,
familyId: null,
disabledAt: null,
sessionVersion: session.user.sessionVersion,
},
data: { familyId: family.id },
});
if (attached.count !== 1) throw new Error("Account already assigned or revoked");
if (attached.count !== 1)
throw new Error("Account already assigned or revoked");
});
revalidatePath("/dashboard/profil");
@@ -178,7 +195,12 @@ export async function createMyFamily(rawPayload: unknown) {
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
if (!(error instanceof Error && error.message === "Account already assigned or revoked")) {
if (
!(
error instanceof Error &&
error.message === "Account already assigned or revoked"
)
) {
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
}
return { error: "Der Haushalt konnte nicht angelegt werden." };
@@ -249,7 +271,11 @@ export async function updateMyChild(childId: string, rawPayload: unknown) {
}
await prisma.child.update({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! },
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId!,
},
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
@@ -271,15 +297,34 @@ export async function deleteMyChild(childId: string) {
try {
await serializableTransaction(async (tx) => {
if (!session.user.familyId) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
if (!session.user.familyId)
throw new UserFacingError(
"Dieses Kind gehört nicht zu deinem Haushalt.",
);
const child = await tx.child.findFirst({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
},
select: { id: true },
});
if (!child) throw new UserFacingError("Dieses Kind gehört nicht zu deinem Haushalt.");
await assertChildrenRemovable(tx, session.user.kitaId, session.user.familyId, [childId]);
if (!child)
throw new UserFacingError(
"Dieses Kind gehört nicht zu deinem Haushalt.",
);
await assertChildrenRemovable(
tx,
session.user.kitaId,
session.user.familyId,
[childId],
);
await tx.child.delete({
where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId },
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
},
});
});
@@ -287,89 +332,67 @@ export async function deleteMyChild(childId: string) {
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
if (!(error instanceof UserFacingError)) console.error("Fehler beim Entfernen des Kindes:", error);
return { error: publicActionError(error, "Das Kind konnte nicht entfernt werden.") };
if (!(error instanceof UserFacingError))
console.error("Fehler beim Entfernen des Kindes:", error);
return {
error: publicActionError(error, "Das Kind konnte nicht entfernt werden."),
};
}
}
export async function deleteMyAccount() {
export async function deleteMyAccount(): Promise<{
success?: boolean;
error?: string;
}> {
const session = await requireKitaSession();
try {
const user = await prisma.user.findFirst({
where: {
id: session.user.id,
kitaId: session.user.kitaId,
},
select: {
id: true,
familyId: true,
},
});
if (!user) {
return { error: "Account wurde nicht gefunden." };
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const activeNotdienstAssignments = user.familyId
? await prisma.notdienstAssignment.count({
where: {
kitaId: session.user.kitaId,
date: { gte: today },
plan: { status: NotdienstPlanStatus.PUBLISHED },
child: { familyId: user.familyId },
},
})
: 0;
if (activeNotdienstAssignments > 0) {
return {
error:
return await serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, session.user.kitaId);
const user = await tx.user.findFirst({
where: { id: session.user.id, kitaId: session.user.kitaId },
select: { id: true, familyId: true },
});
if (!user) throw new UserFacingError("Account wurde nicht gefunden.");
await assertKitaAdminRemains(tx, session.user.kitaId, [user.id]);
const today = new Date();
today.setHours(0, 0, 0, 0);
const assignments = user.familyId
? await tx.notdienstAssignment.count({
where: {
kitaId: session.user.kitaId,
date: { gte: today },
plan: { status: NotdienstPlanStatus.PUBLISHED },
child: { familyId: user.familyId },
},
})
: 0;
if (assignments)
throw new UserFacingError(
"Dein Account kann nicht gelöscht werden, solange aktive Notdienst-Verpflichtungen bestehen.",
};
}
if (!user.familyId) {
await prisma.user.delete({
where: { id: user.id },
});
);
if (user.familyId) {
const family = await tx.family.findFirst({
where: { id: user.familyId, kitaId: session.user.kitaId },
select: { id: true, users: { select: { id: true } } },
});
if (!family)
throw new UserFacingError("Haushalt wurde nicht gefunden.");
if (family.users.length <= 1) {
await tx.family.delete({ where: { id: family.id } });
return { success: true };
}
}
await tx.user.delete({ where: { id: user.id } });
return { success: true };
}
const family = await prisma.family.findFirst({
where: {
id: user.familyId,
kitaId: session.user.kitaId,
},
select: {
id: true,
users: {
select: { id: true },
},
},
});
if (!family) {
return { error: "Haushalt wurde nicht gefunden." };
}
if (family.users.length <= 1) {
await prisma.family.delete({
where: { id: family.id },
});
return { success: true };
}
await prisma.user.delete({
where: { id: user.id },
});
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Accounts:", error);
return { error: "Ein Fehler ist beim Löschen aufgetreten." };
if (!(error instanceof UserFacingError))
console.error("Fehler beim Löschen des Accounts:", error);
return {
error: publicActionError(
error,
"Ein Fehler ist beim Löschen aufgetreten.",
),
};
}
}
+2 -1
View File
@@ -56,6 +56,7 @@ export default async function ProfilPage() {
city: true,
directoryOptInAt: true,
staffDecisionEmails: true,
staffProfile: { select: { id: true } },
createdAt: true,
family: {
select: {
@@ -210,7 +211,7 @@ export default async function ProfilPage() {
</CardContent>
</Card>
{user.role === UserRole.ERZIEHER && (
{user.staffProfile && (
<Card>
<CardHeader>
<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,
Clock3,
BellRing,
ShieldCheck,
} from "lucide-react";
import { NavItem } from "@/components/ui/nav-item";
@@ -21,6 +22,14 @@ import { NavItem } from "@/components/ui/nav-item";
import { UserRole } from "@prisma/client";
const navItems = [
{
section: "Verwaltung",
href: "/dashboard/zugriffsrechte",
label: "Zugriffsrechte",
icon: ShieldCheck,
exact: true,
allowedRoles: [UserRole.ADMIN],
},
{
section: "Heute",
href: "/dashboard/notdienst/alarm",
+3 -9
View File
@@ -1,5 +1,7 @@
import type { UserRole } from "@prisma/client";
import { kitaRoleLabels } from "@/lib/kita-roles";
import { cn } from "@/lib/utils";
type RoleBadgeProps = {
@@ -7,14 +9,6 @@ type RoleBadgeProps = {
className?: string;
};
const roleLabels: Record<string, string> = {
SUPERADMIN: "Admin",
ADMIN: "Vorstand",
KOORDINATOR: "Koordinator",
ELTERN: "Eltern",
ERZIEHER: "Erzieher",
};
export function RoleBadge({ role, className }: RoleBadgeProps) {
return (
<span
@@ -23,7 +17,7 @@ export function RoleBadge({ role, className }: RoleBadgeProps) {
className,
)}
>
{roleLabels[role] ?? role}
{kitaRoleLabels[role]}
</span>
);
}
+18 -3
View File
@@ -39,14 +39,29 @@ export async function deliverAlarmEmails(
include: { user: true, alarm: { include: { kita: true } } },
});
const { user, alarm } = job;
let eligible = user.kitaId === alarm.kitaId && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now);
let eligible =
user.kitaId === alarm.kitaId &&
!user.disabledAt &&
!!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now);
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
else
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
eligible &&=
["ELTERN", "ADMIN", "KOORDINATOR"].includes(user.role) &&
user.familyId === alarm.familyId;
if (job.kind === "REQUEST") {
const token = job.path.split("/").pop();
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
where: { alarmId: alarm.id, userId: user.id, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ token }] : [])] },
where: {
alarmId: alarm.id,
userId: user.id,
sessionVersion: user.sessionVersion,
OR: [
{ token: hashToken(token ?? "") },
...(token?.length === 36 ? [{ token }] : []),
],
},
});
eligible &&=
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
+52 -16
View File
@@ -114,22 +114,30 @@ async function queue(
const users =
audience === "managers"
? await tx.user.findMany({
where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: {
kitaId: alarm.kitaId,
role: { in: managerRoles },
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
})
: await tx.user.findMany({
where: {
kitaId: alarm.kitaId,
familyId: alarm.familyId,
role: "ELTERN",
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null },
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
alarmRecipients: { some: { alarmId: alarm.id } },
},
});
const jobs: string[] = [];
for (const user of users) {
const recipient =
kind === "REQUEST" ? requestTokens?.get(user.id) : null;
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing");
const recipient = kind === "REQUEST" ? requestTokens?.get(user.id) : null;
if (kind === "REQUEST" && !recipient)
throw new Error("Request token missing");
await tx.notdienstAlarmNotification.create({
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
});
@@ -204,7 +212,15 @@ export async function executeAlarmCommand(
include: {
family: {
include: {
users: { where: { role: "ELTERN", kitaId: actor.kitaId, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } },
users: {
where: {
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
kitaId: actor.kitaId,
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
},
},
},
},
@@ -221,10 +237,14 @@ export async function executeAlarmCommand(
if (!assignment.kita.notdienstModuleEnabled)
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
if (assignment.plan.status !== "PUBLISHED")
throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen.");
throw new UserFacingError(
"Bitte den Notdienstplan zuerst veröffentlichen.",
);
const date = dateKey(assignment.date);
if (date < todayKey(now))
throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden.");
throw new UserFacingError(
"Vergangene Notdienste können nicht alarmiert werden.",
);
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
if (expiresAt <= now)
throw new UserFacingError("Dieser Einsatz ist bereits beendet.");
@@ -245,7 +265,9 @@ export async function executeAlarmCommand(
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
);
const parents = assignment.child.family.users;
const requestTokens = new Map(parents.map(parent => [parent.id, newToken()]));
const requestTokens = new Map(
parents.map((parent) => [parent.id, newToken()]),
);
if (!parents.length)
throw new UserFacingError(
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
@@ -353,7 +375,9 @@ export async function executeAlarmCommand(
);
const reason = note.parse(data.reason);
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
throw new UserFacingError("Es liegt keine verspätete Zusage zur Freigabe vor.");
throw new UserFacingError(
"Es liegt keine verspätete Zusage zur Freigabe vor.",
);
const approve =
command === "approveLate" || z.boolean().parse(data.approve);
const updated = await tx.notdienstAlarm.update({
@@ -401,7 +425,12 @@ export async function executeAlarmCommand(
async function recipientForToken(tx: Tx, token: string) {
const recipient = await tx.notdienstAlarmRecipient.findFirst({
where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] },
where: {
OR: [
{ token: hashToken(token) },
...(token.length === 36 ? [{ token }] : []),
],
},
include: {
user: true,
alarm: { include: { kita: { select: { name: true } } } },
@@ -409,9 +438,11 @@ async function recipientForToken(tx: Tx, token: string) {
});
if (
!recipient ||
recipient.user.role !== "ELTERN" ||
!["ELTERN", "ADMIN", "KOORDINATOR"].includes(recipient.user.role) ||
recipient.sessionVersion !== recipient.user.sessionVersion ||
!!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt ||
!!recipient.user.disabledAt ||
!recipient.user.emailVerifiedAt ||
!recipient.user.privacyPolicyAcceptedAt ||
recipient.alarm.expiresAt <= new Date() ||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
recipient.user.kitaId !== recipient.alarm.kitaId ||
@@ -423,7 +454,12 @@ async function recipientForToken(tx: Tx, token: string) {
export async function getPublicAlarm(token: string) {
if (!tokenSchema.safeParse(token).success) return null;
const legacy = await prisma.notdienstAlert.findFirst({
where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ confirmationToken: token }] : [])] },
where: {
OR: [
{ confirmationToken: hashToken(token) },
...(token.length === 36 ? [{ confirmationToken: token }] : []),
],
},
select: { alarmId: true, assignmentId: true },
});
if (legacy && !legacy.alarmId)
@@ -591,7 +627,7 @@ export async function importLegacyAlarms(
const old = assignment.alerts;
const valid = old.filter(
(a) =>
a.parentUser.role === "ELTERN" &&
["ELTERN", "ADMIN", "KOORDINATOR"].includes(a.parentUser.role) &&
a.parentUser.familyId === assignment.child.familyId &&
a.parentUser.kitaId === assignment.kitaId,
);
+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;
let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now);
let eligible =
user.kitaId === profile.kitaId &&
!user.disabledAt &&
(!user.lockedUntil || user.lockedUntil <= now);
if (job.inviteToken) {
const token = await prisma.verificationToken.findUnique({
where: { token: hashToken(job.inviteToken) },
});
eligible &&=
profile.userId === user.id &&
user.role === "ERZIEHER" &&
["ERZIEHER", "ADMIN"].includes(user.role) &&
!user.emailVerifiedAt &&
!!token &&
token.identifier === user.id &&
token.expires > now;
} else {
eligible &&=
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt &&
!!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now) &&
(job.toAdmins
? user.role === "ADMIN"
: user.role === "ERZIEHER" &&
: ["ERZIEHER", "ADMIN"].includes(user.role) &&
profile.userId === user.id &&
user.staffDecisionEmails);
}
+48 -17
View File
@@ -81,7 +81,11 @@ async function inviteStaff(
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
!(
existingUser.role === UserRole.ERZIEHER ||
(existingUser.role === UserRole.ADMIN &&
existingUser.id === profile.userId)
) ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profile.id } },
})))
@@ -205,7 +209,9 @@ export function assertStaffAccess(
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
if (adminOnly) requireAdmin(actor);
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
throw new UserFacingError("Du kannst nur dein eigenes Personalprofil verwalten.");
throw new UserFacingError(
"Du kannst nur dein eigenes Personalprofil verwalten.",
);
}
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
@@ -320,8 +326,8 @@ export async function executeStaffCommand(
kitaId: actor.kitaId,
role: actor.role,
privacyPolicyAcceptedAt: { not: null },
emailVerifiedAt: { not: null },
disabledAt: null,
emailVerifiedAt: { not: null },
disabledAt: null,
},
});
if (
@@ -419,7 +425,9 @@ export async function executeStaffCommand(
if (
profile.user &&
(profile.user.kitaId !== actor.kitaId ||
profile.user.role !== UserRole.ERZIEHER)
!([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes(
profile.user.role,
))
)
throw new UserFacingError(
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
@@ -440,7 +448,11 @@ export async function executeStaffCommand(
return { ...invitation, educatorId: educator.id };
}
if (command === "emailPreference") {
if (actor.role !== UserRole.ERZIEHER)
if (
!(await tx.staffProfile.count({
where: { userId: actor.id, kitaId: actor.kitaId },
}))
)
throw new UserFacingError(
"Diese Einstellung ist für Fachkräfte vorgesehen.",
);
@@ -534,7 +546,9 @@ export async function executeStaffCommand(
input.employmentEnd &&
input.employmentEnd < input.employmentStart
)
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
throw new UserFacingError(
"Das Beschäftigungsende liegt vor dem Beginn.",
);
if (
input.trackingStart < input.employmentStart ||
(input.employmentEnd && input.trackingStart > input.employmentEnd)
@@ -545,7 +559,8 @@ export async function executeStaffCommand(
const educator = await tx.educator.findFirst({
where: { id: input.educatorId, kitaId: actor.kitaId },
});
if (!educator) throw new UserFacingError("Fachkraft nicht gefunden.");
if (!educator)
throw new UserFacingError("Fachkraft nicht gefunden.");
const existing = await tx.staffProfile.findFirst({
where: { educatorId: educator.id, kitaId: actor.kitaId },
include: profileInclude,
@@ -591,7 +606,8 @@ export async function executeStaffCommand(
where: { id: profileId, kitaId: actor.kitaId },
include: profileInclude,
});
if (!profile) throw new UserFacingError("Personalprofil nicht gefunden.");
if (!profile)
throw new UserFacingError("Personalprofil nicht gefunden.");
assertStaffAccess(actor, profile);
const holidays = new Set(
(
@@ -631,7 +647,10 @@ export async function executeStaffCommand(
for (const user of recipients) {
if (
!toAdmins &&
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails)
(!(
[UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]
).includes(user.role) ||
!user.staffDecisionEmails)
)
continue;
const job = await tx.staffEmail.create({
@@ -719,7 +738,9 @@ export async function executeStaffCommand(
input.employmentEnd &&
input.employmentEnd < dateKey(profile.employmentStart)
)
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
throw new UserFacingError(
"Das Beschäftigungsende liegt vor dem Beginn.",
);
if (
input.employmentEnd &&
(input.employmentEnd < dateKey(profile.trackingStart) ||
@@ -786,7 +807,9 @@ export async function executeStaffCommand(
(profile.employmentEnd &&
input.date > dateKey(profile.employmentEnd))
)
throw new UserFacingError("Der Tag liegt außerhalb der Beschäftigung.");
throw new UserFacingError(
"Der Tag liegt außerhalb der Beschäftigung.",
);
if (input.remove)
await tx.staffDayOverride.deleteMany({
where: { profileId, date: dateOnly(input.date) },
@@ -859,7 +882,9 @@ export async function executeStaffCommand(
})
.parse(data);
if (input.date < dateKey(profile.trackingStart))
throw new UserFacingError("Korrekturen müssen ab Erfassungsbeginn liegen.");
throw new UserFacingError(
"Korrekturen müssen ab Erfassungsbeginn liegen.",
);
await tx.staffAdjustment.create({
data: {
profileId,
@@ -887,7 +912,8 @@ export async function executeStaffCommand(
throw new UserFacingError("Zeiteintrag nicht gefunden.");
if (existing) ensureDayEditable(profile, dateKey(existing.date));
if (command === "deleteTime") {
if (!existing) throw new UserFacingError("Zeiteintrag nicht gefunden.");
if (!existing)
throw new UserFacingError("Zeiteintrag nicht gefunden.");
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
await history(
"Arbeitszeit gelöscht",
@@ -1135,7 +1161,8 @@ export async function executeStaffCommand(
const existing = data.id
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
: undefined;
if (data.id && !existing) throw new UserFacingError("Antrag nicht gefunden.");
if (data.id && !existing)
throw new UserFacingError("Antrag nicht gefunden.");
if (command === "leaveCancel") {
if (
!existing ||
@@ -1182,7 +1209,9 @@ export async function executeStaffCommand(
!existing ||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
)
throw new UserFacingError("Dieser Antrag ist nicht mehr offen.");
throw new UserFacingError(
"Dieser Antrag ist nicht mehr offen.",
);
const cancelling = existing.status === "CANCEL_REQUESTED";
if (input.approve)
ensureRangeEditable(
@@ -1284,7 +1313,9 @@ export async function executeStaffCommand(
.parse(data);
if (input.kind === "SICKNESS") requireAdmin(actor);
if (existing && existing.status !== "PENDING")
throw new UserFacingError("Nur offene Anträge können bearbeitet werden.");
throw new UserFacingError(
"Nur offene Anträge können bearbeitet werden.",
);
if (input.startDate < dateKey(profile.trackingStart))
throw new UserFacingError(
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",