feat: show planning availability and manage Kita access roles

This commit is contained in:
t.indorf
2026-10-12 16:28:56 +02:00
parent 99d3a63eed
commit 061024d901
29 changed files with 2347 additions and 404 deletions
+18 -3
View File
@@ -39,14 +39,29 @@ export async function deliverAlarmEmails(
include: { user: true, alarm: { include: { kita: true } } },
});
const { user, alarm } = job;
let eligible = user.kitaId === alarm.kitaId && !user.disabledAt && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && (!user.lockedUntil || user.lockedUntil <= now);
let eligible =
user.kitaId === alarm.kitaId &&
!user.disabledAt &&
!!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now);
if (job.kind === "MANAGER") eligible &&= managerRoles.includes(user.role);
else
eligible &&= user.role === "ELTERN" && user.familyId === alarm.familyId;
eligible &&=
["ELTERN", "ADMIN", "KOORDINATOR"].includes(user.role) &&
user.familyId === alarm.familyId;
if (job.kind === "REQUEST") {
const token = job.path.split("/").pop();
const recipient = await prisma.notdienstAlarmRecipient.findFirst({
where: { alarmId: alarm.id, userId: user.id, sessionVersion: user.sessionVersion, OR: [{ token: hashToken(token ?? "") }, ...(token?.length === 36 ? [{ token }] : [])] },
where: {
alarmId: alarm.id,
userId: user.id,
sessionVersion: user.sessionVersion,
OR: [
{ token: hashToken(token ?? "") },
...(token?.length === 36 ? [{ token }] : []),
],
},
});
eligible &&=
!!recipient && alarm.status === "PENDING" && alarm.expiresAt > now;
+52 -16
View File
@@ -114,22 +114,30 @@ async function queue(
const users =
audience === "managers"
? await tx.user.findMany({
where: { kitaId: alarm.kitaId, role: { in: managerRoles }, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
where: {
kitaId: alarm.kitaId,
role: { in: managerRoles },
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
})
: await tx.user.findMany({
where: {
kitaId: alarm.kitaId,
familyId: alarm.familyId,
role: "ELTERN",
emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null },
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
alarmRecipients: { some: { alarmId: alarm.id } },
},
});
const jobs: string[] = [];
for (const user of users) {
const recipient =
kind === "REQUEST" ? requestTokens?.get(user.id) : null;
if (kind === "REQUEST" && !recipient) throw new Error("Request token missing");
const recipient = kind === "REQUEST" ? requestTokens?.get(user.id) : null;
if (kind === "REQUEST" && !recipient)
throw new Error("Request token missing");
await tx.notdienstAlarmNotification.create({
data: { alarmId: alarm.id, userId: user.id, eventKey, message },
});
@@ -204,7 +212,15 @@ export async function executeAlarmCommand(
include: {
family: {
include: {
users: { where: { role: "ELTERN", kitaId: actor.kitaId, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } } },
users: {
where: {
role: { in: ["ELTERN", "ADMIN", "KOORDINATOR"] },
kitaId: actor.kitaId,
emailVerifiedAt: { not: null },
disabledAt: null,
privacyPolicyAcceptedAt: { not: null },
},
},
},
},
},
@@ -221,10 +237,14 @@ export async function executeAlarmCommand(
if (!assignment.kita.notdienstModuleEnabled)
throw new UserFacingError("Notdienst ist für diese Kita deaktiviert.");
if (assignment.plan.status !== "PUBLISHED")
throw new UserFacingError("Bitte den Notdienstplan zuerst veröffentlichen.");
throw new UserFacingError(
"Bitte den Notdienstplan zuerst veröffentlichen.",
);
const date = dateKey(assignment.date);
if (date < todayKey(now))
throw new UserFacingError("Vergangene Notdienste können nicht alarmiert werden.");
throw new UserFacingError(
"Vergangene Notdienste können nicht alarmiert werden.",
);
const expiresAt = endOfAlarm(date, input.wholeDay, input.endMinute);
if (expiresAt <= now)
throw new UserFacingError("Dieser Einsatz ist bereits beendet.");
@@ -245,7 +265,9 @@ export async function executeAlarmCommand(
"Für diese Einteilung besteht bereits ein aktiver Alarm.",
);
const parents = assignment.child.family.users;
const requestTokens = new Map(parents.map(parent => [parent.id, newToken()]));
const requestTokens = new Map(
parents.map((parent) => [parent.id, newToken()]),
);
if (!parents.length)
throw new UserFacingError(
"Für diesen Haushalt sind keine Elternkonten hinterlegt.",
@@ -353,7 +375,9 @@ export async function executeAlarmCommand(
);
const reason = note.parse(data.reason);
if (command === "approveLate" && alarm.status !== "LATE_CONFIRMATION")
throw new UserFacingError("Es liegt keine verspätete Zusage zur Freigabe vor.");
throw new UserFacingError(
"Es liegt keine verspätete Zusage zur Freigabe vor.",
);
const approve =
command === "approveLate" || z.boolean().parse(data.approve);
const updated = await tx.notdienstAlarm.update({
@@ -401,7 +425,12 @@ export async function executeAlarmCommand(
async function recipientForToken(tx: Tx, token: string) {
const recipient = await tx.notdienstAlarmRecipient.findFirst({
where: { OR: [{ token: hashToken(token) }, ...(token.length === 36 ? [{ token }] : [])] },
where: {
OR: [
{ token: hashToken(token) },
...(token.length === 36 ? [{ token }] : []),
],
},
include: {
user: true,
alarm: { include: { kita: { select: { name: true } } } },
@@ -409,9 +438,11 @@ async function recipientForToken(tx: Tx, token: string) {
});
if (
!recipient ||
recipient.user.role !== "ELTERN" ||
!["ELTERN", "ADMIN", "KOORDINATOR"].includes(recipient.user.role) ||
recipient.sessionVersion !== recipient.user.sessionVersion ||
!!recipient.user.disabledAt || !recipient.user.emailVerifiedAt || !recipient.user.privacyPolicyAcceptedAt ||
!!recipient.user.disabledAt ||
!recipient.user.emailVerifiedAt ||
!recipient.user.privacyPolicyAcceptedAt ||
recipient.alarm.expiresAt <= new Date() ||
(recipient.user.lockedUntil && recipient.user.lockedUntil > new Date()) ||
recipient.user.kitaId !== recipient.alarm.kitaId ||
@@ -423,7 +454,12 @@ async function recipientForToken(tx: Tx, token: string) {
export async function getPublicAlarm(token: string) {
if (!tokenSchema.safeParse(token).success) return null;
const legacy = await prisma.notdienstAlert.findFirst({
where: { OR: [{ confirmationToken: hashToken(token) }, ...(token.length === 36 ? [{ confirmationToken: token }] : [])] },
where: {
OR: [
{ confirmationToken: hashToken(token) },
...(token.length === 36 ? [{ confirmationToken: token }] : []),
],
},
select: { alarmId: true, assignmentId: true },
});
if (legacy && !legacy.alarmId)
@@ -591,7 +627,7 @@ export async function importLegacyAlarms(
const old = assignment.alerts;
const valid = old.filter(
(a) =>
a.parentUser.role === "ELTERN" &&
["ELTERN", "ADMIN", "KOORDINATOR"].includes(a.parentUser.role) &&
a.parentUser.familyId === assignment.child.familyId &&
a.parentUser.kitaId === assignment.kitaId,
);
+41
View File
@@ -0,0 +1,41 @@
import type { Prisma } from "@prisma/client";
import { UserFacingError } from "@/lib/action-error";
// Serialize role changes, account deletion and support locks for the same Kita.
export async function lockKitaAccounts(
tx: Prisma.TransactionClient,
kitaId: string,
) {
const rows = await tx.$queryRaw<
{ id: string }[]
>`SELECT id FROM kitas WHERE id = ${kitaId} FOR UPDATE`;
if (!rows.length) throw new UserFacingError("Kita wurde nicht gefunden.");
}
export async function assertKitaAdminRemains(
tx: Prisma.TransactionClient,
kitaId: string,
excludedUserIds: string[],
) {
const removesAdmin = await tx.user.count({
where: { kitaId, id: { in: excludedUserIds }, role: "ADMIN" },
});
if (!removesAdmin) return;
const now = new Date();
const remaining = await tx.user.count({
where: {
kitaId,
id: { notIn: excludedUserIds },
role: "ADMIN",
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
passwordHash: { not: "" },
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
},
});
if (!remaining)
throw new UserFacingError(
"Mindestens ein aktiver Kita-Admin muss erhalten bleiben. Ernenne zuerst eine weitere Person mit aktiviertem Zugang und Datenschutzzustimmung.",
);
}
+116
View File
@@ -0,0 +1,116 @@
import "server-only";
import { AuditTargetType } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { serializableTransaction } from "@/lib/transaction";
import { UserFacingError } from "@/lib/action-error";
import {
assertKitaAdminRemains,
lockKitaAccounts,
} from "@/lib/kita-admin-guard";
import { availableKitaRoles } from "@/lib/kita-roles";
const inputSchema = z.object({
userId: z.string().min(1).max(64),
role: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]),
expectedRole: z.enum(["ADMIN", "KOORDINATOR", "ELTERN", "ERZIEHER"]),
});
type Actor = { id: string; kitaId: string; sessionVersion: number };
export async function changeKitaRole(actor: Actor, raw: unknown) {
const input = inputSchema.safeParse(raw);
if (!input.success) throw new UserFacingError("Ungültige Rollenänderung.");
return serializableTransaction(async (tx) => {
await lockKitaAccounts(tx, actor.kitaId);
const currentActor = await tx.user.findFirst({
where: {
id: actor.id,
kitaId: actor.kitaId,
role: "ADMIN",
sessionVersion: actor.sessionVersion,
disabledAt: null,
emailVerifiedAt: { not: null },
privacyPolicyAcceptedAt: { not: null },
OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }],
},
select: { id: true, email: true },
});
if (!currentActor)
throw new UserFacingError(
"Du darfst die Zugriffsrechte nicht mehr ändern. Bitte lade die Seite neu.",
);
const person = await tx.user.findFirst({
where: { id: input.data.userId, kitaId: actor.kitaId },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
role: true,
familyId: true,
staffProfile: { select: { id: true, kitaId: true } },
},
});
if (!person || person.role === "SUPERADMIN")
throw new UserFacingError(
"Dieses Kita-Konto kann hier nicht geändert werden.",
);
if (person.role !== input.data.expectedRole)
throw new UserFacingError(
"Die Rolle wurde inzwischen geändert. Bitte lade die Seite neu.",
);
if (person.staffProfile && person.staffProfile.kitaId !== actor.kitaId)
throw new UserFacingError(
"Das Personalprofil gehört nicht zu dieser Kita.",
);
if (
!availableKitaRoles({
familyId: person.familyId,
hasStaffProfile: !!person.staffProfile,
}).includes(input.data.role)
)
throw new UserFacingError(
person.staffProfile
? "Ein Personalprofil benötigt die Rolle Fachkraft oder Kita-Admin."
: "Eltern benötigen einen Haushalt; die Fachkraftrolle benötigt ein Personalprofil.",
);
if (person.role === input.data.role)
return { changed: false, ownRoleChanged: false };
if (person.role === "ADMIN")
await assertKitaAdminRemains(tx, actor.kitaId, [person.id]);
await tx.user.update({
where: { id: person.id, kitaId: actor.kitaId },
data: { role: input.data.role },
});
await tx.auditLog.create({
data: {
actorUserId: currentActor.id,
actorEmail: currentActor.email,
action: "USER_ROLE_CHANGE",
targetType: AuditTargetType.USER,
targetId: person.id,
targetLabel: `${person.firstName} ${person.lastName}`,
targetKitaId: actor.kitaId,
metadata: { previousRole: person.role, newRole: input.data.role },
},
});
// Auth reloads the role from the database on every request. Keeping sessionVersion
// preserves already-issued household alarm links and existing login sessions.
return { changed: true, ownRoleChanged: person.id === actor.id };
});
}
export async function getKitaRoleHistory(kitaId: string) {
return prisma.auditLog.findMany({
where: { targetKitaId: kitaId, action: "USER_ROLE_CHANGE" },
orderBy: { createdAt: "desc" },
take: 15,
select: {
id: true,
targetLabel: true,
actorEmail: true,
metadata: true,
createdAt: true,
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import type { UserRole } from "@prisma/client";
export const kitaRoleLabels: Record<UserRole, string> = {
ADMIN: "Kita-Admin",
KOORDINATOR: "Notdienst-Koordination",
ELTERN: "Eltern",
ERZIEHER: "Fachkraft",
SUPERADMIN: "Systemadmin",
};
export function availableKitaRoles(person: {
familyId: string | null;
hasStaffProfile: boolean;
}): UserRole[] {
if (person.hasStaffProfile) return ["ERZIEHER", "ADMIN"];
return person.familyId
? ["ELTERN", "KOORDINATOR", "ADMIN"]
: ["KOORDINATOR", "ADMIN"];
}
+8 -4
View File
@@ -40,25 +40,29 @@ export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
},
});
const { user, profile } = job;
let eligible = user.kitaId === profile.kitaId && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now);
let eligible =
user.kitaId === profile.kitaId &&
!user.disabledAt &&
(!user.lockedUntil || user.lockedUntil <= now);
if (job.inviteToken) {
const token = await prisma.verificationToken.findUnique({
where: { token: hashToken(job.inviteToken) },
});
eligible &&=
profile.userId === user.id &&
user.role === "ERZIEHER" &&
["ERZIEHER", "ADMIN"].includes(user.role) &&
!user.emailVerifiedAt &&
!!token &&
token.identifier === user.id &&
token.expires > now;
} else {
eligible &&=
!!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt &&
!!user.emailVerifiedAt &&
!!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now) &&
(job.toAdmins
? user.role === "ADMIN"
: user.role === "ERZIEHER" &&
: ["ERZIEHER", "ADMIN"].includes(user.role) &&
profile.userId === user.id &&
user.staffDecisionEmails);
}
+48 -17
View File
@@ -81,7 +81,11 @@ async function inviteStaff(
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
!(
existingUser.role === UserRole.ERZIEHER ||
(existingUser.role === UserRole.ADMIN &&
existingUser.id === profile.userId)
) ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profile.id } },
})))
@@ -205,7 +209,9 @@ export function assertStaffAccess(
throw new UserFacingError("Kein Zugriff auf dieses Personalprofil.");
if (adminOnly) requireAdmin(actor);
else if (actor.role !== UserRole.ADMIN && profile.userId !== actor.id)
throw new UserFacingError("Du kannst nur dein eigenes Personalprofil verwalten.");
throw new UserFacingError(
"Du kannst nur dein eigenes Personalprofil verwalten.",
);
}
export async function getStaffProfile(actor: StaffActor, profileId?: string) {
@@ -320,8 +326,8 @@ export async function executeStaffCommand(
kitaId: actor.kitaId,
role: actor.role,
privacyPolicyAcceptedAt: { not: null },
emailVerifiedAt: { not: null },
disabledAt: null,
emailVerifiedAt: { not: null },
disabledAt: null,
},
});
if (
@@ -419,7 +425,9 @@ export async function executeStaffCommand(
if (
profile.user &&
(profile.user.kitaId !== actor.kitaId ||
profile.user.role !== UserRole.ERZIEHER)
!([UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]).includes(
profile.user.role,
))
)
throw new UserFacingError(
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
@@ -440,7 +448,11 @@ export async function executeStaffCommand(
return { ...invitation, educatorId: educator.id };
}
if (command === "emailPreference") {
if (actor.role !== UserRole.ERZIEHER)
if (
!(await tx.staffProfile.count({
where: { userId: actor.id, kitaId: actor.kitaId },
}))
)
throw new UserFacingError(
"Diese Einstellung ist für Fachkräfte vorgesehen.",
);
@@ -534,7 +546,9 @@ export async function executeStaffCommand(
input.employmentEnd &&
input.employmentEnd < input.employmentStart
)
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
throw new UserFacingError(
"Das Beschäftigungsende liegt vor dem Beginn.",
);
if (
input.trackingStart < input.employmentStart ||
(input.employmentEnd && input.trackingStart > input.employmentEnd)
@@ -545,7 +559,8 @@ export async function executeStaffCommand(
const educator = await tx.educator.findFirst({
where: { id: input.educatorId, kitaId: actor.kitaId },
});
if (!educator) throw new UserFacingError("Fachkraft nicht gefunden.");
if (!educator)
throw new UserFacingError("Fachkraft nicht gefunden.");
const existing = await tx.staffProfile.findFirst({
where: { educatorId: educator.id, kitaId: actor.kitaId },
include: profileInclude,
@@ -591,7 +606,8 @@ export async function executeStaffCommand(
where: { id: profileId, kitaId: actor.kitaId },
include: profileInclude,
});
if (!profile) throw new UserFacingError("Personalprofil nicht gefunden.");
if (!profile)
throw new UserFacingError("Personalprofil nicht gefunden.");
assertStaffAccess(actor, profile);
const holidays = new Set(
(
@@ -631,7 +647,10 @@ export async function executeStaffCommand(
for (const user of recipients) {
if (
!toAdmins &&
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails)
(!(
[UserRole.ERZIEHER, UserRole.ADMIN] as UserRole[]
).includes(user.role) ||
!user.staffDecisionEmails)
)
continue;
const job = await tx.staffEmail.create({
@@ -719,7 +738,9 @@ export async function executeStaffCommand(
input.employmentEnd &&
input.employmentEnd < dateKey(profile.employmentStart)
)
throw new UserFacingError("Das Beschäftigungsende liegt vor dem Beginn.");
throw new UserFacingError(
"Das Beschäftigungsende liegt vor dem Beginn.",
);
if (
input.employmentEnd &&
(input.employmentEnd < dateKey(profile.trackingStart) ||
@@ -786,7 +807,9 @@ export async function executeStaffCommand(
(profile.employmentEnd &&
input.date > dateKey(profile.employmentEnd))
)
throw new UserFacingError("Der Tag liegt außerhalb der Beschäftigung.");
throw new UserFacingError(
"Der Tag liegt außerhalb der Beschäftigung.",
);
if (input.remove)
await tx.staffDayOverride.deleteMany({
where: { profileId, date: dateOnly(input.date) },
@@ -859,7 +882,9 @@ export async function executeStaffCommand(
})
.parse(data);
if (input.date < dateKey(profile.trackingStart))
throw new UserFacingError("Korrekturen müssen ab Erfassungsbeginn liegen.");
throw new UserFacingError(
"Korrekturen müssen ab Erfassungsbeginn liegen.",
);
await tx.staffAdjustment.create({
data: {
profileId,
@@ -887,7 +912,8 @@ export async function executeStaffCommand(
throw new UserFacingError("Zeiteintrag nicht gefunden.");
if (existing) ensureDayEditable(profile, dateKey(existing.date));
if (command === "deleteTime") {
if (!existing) throw new UserFacingError("Zeiteintrag nicht gefunden.");
if (!existing)
throw new UserFacingError("Zeiteintrag nicht gefunden.");
await tx.staffTimeEntry.delete({ where: { id: existing.id } });
await history(
"Arbeitszeit gelöscht",
@@ -1135,7 +1161,8 @@ export async function executeStaffCommand(
const existing = data.id
? profile.leaves.find((l) => l.id === idSchema.parse(data.id))
: undefined;
if (data.id && !existing) throw new UserFacingError("Antrag nicht gefunden.");
if (data.id && !existing)
throw new UserFacingError("Antrag nicht gefunden.");
if (command === "leaveCancel") {
if (
!existing ||
@@ -1182,7 +1209,9 @@ export async function executeStaffCommand(
!existing ||
!["PENDING", "CANCEL_REQUESTED"].includes(existing.status)
)
throw new UserFacingError("Dieser Antrag ist nicht mehr offen.");
throw new UserFacingError(
"Dieser Antrag ist nicht mehr offen.",
);
const cancelling = existing.status === "CANCEL_REQUESTED";
if (input.approve)
ensureRangeEditable(
@@ -1284,7 +1313,9 @@ export async function executeStaffCommand(
.parse(data);
if (input.kind === "SICKNESS") requireAdmin(actor);
if (existing && existing.status !== "PENDING")
throw new UserFacingError("Nur offene Anträge können bearbeitet werden.");
throw new UserFacingError(
"Nur offene Anträge können bearbeitet werden.",
);
if (input.startDate < dateKey(profile.trackingStart))
throw new UserFacingError(
"Abwesenheiten bitte ab Erfassungsbeginn erfassen.",