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