Improve family invitations and absence management flows

This commit is contained in:
t.indorf
2026-10-09 22:59:36 +02:00
parent 4702bf5abb
commit 8915fe7df6
29 changed files with 2803 additions and 617 deletions
+127 -119
View File
@@ -1,44 +1,53 @@
"use server";
import crypto from "crypto";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
import { z } from "zod";
import { InviteEmail } from "@/emails/InviteEmail";
import { requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import {
deliverFamilyInvitations,
queueFamilyInvitation,
resendFamilyInvitation,
} from "@/lib/family-invitations";
import { prisma } from "@/lib/prisma";
const INVITE_TOKEN_TTL_DAYS = 7;
const BASE_URL =
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const childSchema = z.object({
firstName: z.string().min(1, "Vorname des Kindes fehlt.").max(100).trim(),
lastName: z.string().min(1, "Nachname des Kindes fehlt.").max(100).trim(),
firstName: z.string().trim().min(1, "Vorname des Kindes fehlt.").max(100),
lastName: z.string().trim().min(1, "Nachname des Kindes fehlt.").max(100),
});
const parentInputSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.trim()
.toLowerCase()
.trim(),
.email("Bitte eine gültige E-Mail-Adresse angeben."),
});
const addFamilySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parent1FirstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
parent1LastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
familyName: z
.string()
.trim()
.min(1, "Familienname ist erforderlich.")
.max(120),
parent1FirstName: z
.string()
.trim()
.min(1, "Vorname ist erforderlich.")
.max(100),
parent1LastName: z
.string()
.trim()
.min(1, "Nachname ist erforderlich.")
.max(100),
parent1Email: z
.string()
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.trim()
.toLowerCase()
.trim(),
.email("Bitte eine gültige E-Mail-Adresse angeben."),
parent2FirstName: z.string().max(100).trim().optional(),
parent2LastName: z.string().max(100).trim().optional(),
parent2Email: z.string().trim().optional(),
@@ -51,12 +60,19 @@ const addFamilySchema = z.object({
const updateFamilySchema = z.object({
familyId: z.string().min(1),
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
parents: z.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
).min(1).max(2),
familyName: z
.string()
.trim()
.min(1, "Familienname ist erforderlich.")
.max(120),
parents: z
.array(
parentInputSchema.extend({
id: z.string().min(1),
}),
)
.min(1)
.max(2),
newParents: z.array(parentInputSchema).max(1).default([]),
children: z
.array(
@@ -82,12 +98,7 @@ export type AddFamilyState = {
_form?: string[];
};
success?: boolean;
};
type InviteJob = {
to: string;
parentName: string;
inviteUrl: string;
warning?: string;
};
function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
@@ -114,32 +125,14 @@ function optionalParentFromForm(data: z.infer<typeof addFamilySchema>) {
return { ok: true as const, parent: parsed.data };
}
function createInviteToken() {
const token = crypto.randomUUID();
const expires = new Date(
Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000,
);
return { token, expires, inviteUrl: `${BASE_URL}/invite/${token}` };
}
async function sendInviteJobs(kitaName: string, jobs: InviteJob[]) {
for (const job of jobs) {
const result = await sendAppEmail({
to: job.to,
subject: `Einladung zu ${kitaName} im Kita-Planer`,
react: createElement(InviteEmail, {
parentName: job.parentName,
kitaName,
inviteLink: job.inviteUrl,
}),
});
if (!result.success) {
return result.error;
}
async function sendInviteJobs(ids: string[]) {
try {
const result = await deliverFamilyInvitations(ids);
if (!result.failed) return undefined;
} catch {
// A committed family remains successful even if delivery/status storage fails.
}
return null;
return "Familie gespeichert. Mindestens eine Einladung konnte nicht versendet werden. Den Versand kannst du beim jeweiligen Elternteil erneut versuchen.";
}
export async function addFamilyAction(
@@ -182,11 +175,6 @@ export async function addFamilyAction(
childrenRaw.push(parsed.data);
}
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { errors: { _form: [mailConfigError] } };
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
@@ -208,12 +196,14 @@ export async function addFamilyAction(
if (emailSet.size !== parents.length) {
return {
errors: {
_form: ["Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein."],
_form: [
"Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
],
},
};
}
const inviteJobs: InviteJob[] = [];
const inviteJobs: string[] = [];
try {
await prisma.$transaction(async (tx) => {
@@ -236,19 +226,12 @@ export async function addFamilyAction(
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
expires: invite.expires,
},
});
const invite = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
await tx.child.createMany({
@@ -271,26 +254,25 @@ export async function addFamilyAction(
},
};
}
throw err;
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
console.error("Familie konnte nicht angelegt werden:", err);
return {
errors: {
_form: [
`Familie wurde angelegt, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
"Familie konnte nicht angelegt werden. Bitte erneut versuchen.",
],
},
};
}
const warning = await sendInviteJobs(inviteJobs);
revalidatePath("/dashboard/families");
revalidatePath("/dashboard");
return { success: true };
return { success: true, warning };
}
export async function updateFamilyAction(rawPayload: unknown) {
export async function updateFamilyAction(
rawPayload: unknown,
): Promise<{ success?: boolean; error?: string; warning?: string }> {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
@@ -313,24 +295,23 @@ export async function updateFamilyAction(rawPayload: unknown) {
removedChildIds,
} = parsed.data;
const parentEmails = [...parents, ...newParents].map((parent) => parent.email);
const parentEmails = [...parents, ...newParents].map(
(parent) => parent.email,
);
if (new Set(parentEmails).size !== parentEmails.length) {
return { error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein." };
return {
error: "Die E-Mail-Adressen der Elternteile müssen unterschiedlich sein.",
};
}
const removedChildIdSet = new Set(removedChildIds);
const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id));
const activeChildren = children.filter(
(child) => !removedChildIdSet.has(child.id),
);
if (activeChildren.length + newChildren.length === 0) {
return { error: "Eine Familie benötigt mindestens ein Kind." };
}
if (newParents.length > 0) {
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
}
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
@@ -340,7 +321,7 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Kita wurde nicht gefunden." };
}
const inviteJobs: InviteJob[] = [];
const inviteJobs: string[] = [];
try {
const existingFamily = await prisma.family.findFirst({
@@ -356,11 +337,17 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Familie wurde nicht gefunden." };
}
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 { error: "Mindestens ein Elternteil gehört nicht zu dieser Familie." };
return {
error: "Mindestens ein Elternteil gehört nicht zu dieser Familie.",
};
}
if (!children.every((child) => existingChildIds.has(child.id))) {
@@ -382,6 +369,9 @@ export async function updateFamilyAction(rawPayload: unknown) {
});
for (const parent of parents) {
const previous = await tx.user.findUniqueOrThrow({
where: { id: parent.id },
});
await tx.user.update({
where: { id: parent.id },
data: {
@@ -390,6 +380,14 @@ export async function updateFamilyAction(rawPayload: unknown) {
email: parent.email,
},
});
if (!previous.emailVerifiedAt && previous.email !== parent.email) {
const invite = await queueFamilyInvitation(
tx,
parent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
}
for (const parent of newParents) {
@@ -404,19 +402,12 @@ export async function updateFamilyAction(rawPayload: unknown) {
role: UserRole.ELTERN,
},
});
const invite = createInviteToken();
inviteJobs.push({
to: parent.email,
parentName: `${parent.firstName} ${parent.lastName}`,
inviteUrl: invite.inviteUrl,
});
await tx.verificationToken.create({
data: {
identifier: createdParent.id,
token: invite.token,
expires: invite.expires,
},
});
const invite = await queueFamilyInvitation(
tx,
createdParent.id,
session.user.id,
);
inviteJobs.push(invite.id);
}
for (const child of activeChildren) {
@@ -456,16 +447,33 @@ export async function updateFamilyAction(rawPayload: unknown) {
return { error: "Familie konnte nicht aktualisiert werden." };
}
const emailError = await sendInviteJobs(kita.name, inviteJobs);
if (emailError) {
return {
error: `Familie wurde aktualisiert, aber mindestens eine Einladung konnte nicht versendet werden: ${emailError}`,
};
}
const warning = await sendInviteJobs(inviteJobs);
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard");
return { success: true };
return { success: true, warning };
}
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)
return { error: "Ungültiges Elternkonto." };
let result;
try {
result = await resendFamilyInvitation(
session.user.kitaId,
userId,
session.user.id,
);
} catch {
result = {
error:
"Einladung konnte nicht erneut versendet werden. Bitte den Status prüfen und erneut versuchen.",
};
}
revalidatePath("/dashboard/families");
return result;
}