feat: show planning availability and manage Kita access roles
This commit is contained in:
+91
-54
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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." };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user