feat: create and invite educators in a single form

This commit is contained in:
t.indorf
2026-10-09 21:59:04 +02:00
parent 979f795341
commit 4702bf5abb
8 changed files with 785 additions and 177 deletions
+221 -70
View File
@@ -28,11 +28,123 @@ export type StaffNotice = {
toAdmins: boolean;
};
type CommandResult = {
educatorId?: string;
emailIds?: string[];
notice?: StaffNotice;
invite?: { email: string; name: string; token: string; kitaName: string };
};
const idSchema = z.string().min(1).max(100);
const educatorInput = z.object({
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
active: z.boolean().default(true),
email: z
.string()
.trim()
.max(254)
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase(),
});
const onboardingDates = z
.object({
employmentStart: dateSchema,
trackingStart: dateSchema,
})
.refine((input) => input.trackingStart >= input.employmentStart, {
message:
"Der Start der Zeiterfassung darf nicht vor dem Beschäftigungsbeginn liegen.",
});
async function inviteStaff(
tx: Prisma.TransactionClient,
actor: StaffActor,
profile: {
id: string;
kitaId: string;
userId: string | null;
user: { email: string; emailVerifiedAt: Date | null } | null;
educator: { firstName: string; lastName: string };
},
email: string,
): Promise<CommandResult> {
assertStaffAccess(actor, profile, true);
if (
profile.userId &&
(profile.user?.email !== email || profile.user.emailVerifiedAt)
)
throw new Error(
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
);
const existingUser = await tx.user.findUnique({ where: { email } });
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profile.id } },
})))
)
throw new Error(
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
);
const user =
existingUser ??
(await tx.user.create({
data: {
email,
firstName: profile.educator.firstName,
lastName: profile.educator.lastName,
passwordHash: "",
role: UserRole.ERZIEHER,
kitaId: actor.kitaId,
},
}));
await tx.staffProfile.update({
where: { id: profile.id },
data: { userId: user.id },
});
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: "Account verknüpft",
details: email,
},
});
if (user.emailVerifiedAt) return {};
const token = randomUUID();
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
await tx.verificationToken.create({
data: {
identifier: user.id,
token,
expires: new Date(Date.now() + 7 * 86400000),
},
});
const kita = await tx.kita.findUniqueOrThrow({
where: { id: actor.kitaId },
select: { name: true },
});
const job = await tx.staffEmail.create({
data: {
profileId: profile.id,
userId: user.id,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
message: "Account aktivieren",
path: `/invite/${token}`,
inviteToken: token,
},
});
return {
emailIds: [job.id],
invite: {
email,
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
token,
kitaName: kita.name,
},
};
}
const reasonSchema = z
.string()
.trim()
@@ -215,6 +327,114 @@ export async function executeStaffCommand(
throw new Error(
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
);
if (
["educatorCreate", "educatorUpdate", "educatorInvite"].includes(
command,
)
) {
requireAdmin(actor);
if (command === "educatorInvite") {
const educator = await tx.educator.findFirst({
where: {
id: idSchema.parse(data.educatorId),
kitaId: actor.kitaId,
},
include: {
staffProfile: { include: { user: true, educator: true } },
},
});
if (!educator?.staffProfile?.user)
throw new Error(
"Bitte zuerst E-Mail-Adresse und Personalprofil ergänzen.",
);
return inviteStaff(
tx,
actor,
educator.staffProfile,
educator.staffProfile.user.email,
);
}
const input = educatorInput.parse(data);
const existing =
command === "educatorUpdate"
? await tx.educator.findFirst({
where: {
id: idSchema.parse(data.educatorId),
kitaId: actor.kitaId,
},
include: { staffProfile: { include: { user: true } } },
})
: null;
if (command === "educatorUpdate" && !existing)
throw new Error("Fachkraft nicht gefunden.");
if (
existing?.staffProfile?.user &&
existing.staffProfile.user.email !== input.email
)
throw new Error(
"Die Adresse eines verknüpften Accounts kann hier nicht geändert werden.",
);
if (existing?.staffProfile)
assertStaffAccess(actor, existing.staffProfile, true);
// Dates are only supplied when creating the profile. Existing balances and dates stay intact.
const dates = !existing?.staffProfile
? onboardingDates.parse(data)
: null;
const values = {
firstName: input.firstName,
lastName: input.lastName,
active: input.active,
};
const educator = existing
? await tx.educator.update({
where: { id: existing.id },
data: values,
})
: await tx.educator.create({
data: { ...values, kitaId: actor.kitaId },
});
const profile =
existing?.staffProfile ??
(await tx.staffProfile.create({
data: {
kitaId: actor.kitaId,
educatorId: educator.id,
employmentStart: dateOnly(dates!.employmentStart),
trackingStart: dateOnly(dates!.trackingStart),
},
include: { user: true },
}));
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: existing ? "Stammdaten geändert" : "Fachkraft angelegt",
details: `${educator.firstName} ${educator.lastName}${dates ? ` · Beschäftigung ${dates.employmentStart}, Erfassung ${dates.trackingStart}, Anfangssaldo 0 Min.` : ""}`,
},
});
if (
profile.user &&
(profile.user.kitaId !== actor.kitaId ||
profile.user.role !== UserRole.ERZIEHER)
)
throw new Error(
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
);
if (profile.userId) {
await tx.user.update({
where: { id: profile.userId },
data: { firstName: input.firstName, lastName: input.lastName },
});
return { educatorId: educator.id };
}
const invitation = await inviteStaff(
tx,
actor,
{ ...profile, educator },
input.email,
);
return { ...invitation, educatorId: educator.id };
}
if (command === "emailPreference") {
if (actor.role !== UserRole.ERZIEHER)
throw new Error(
@@ -445,76 +665,7 @@ export async function executeStaffCommand(
.email()
.toLowerCase()
.parse(data.email);
if (profile.userId) {
if (profile.user?.email !== email || profile.user.emailVerifiedAt)
throw new Error(
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
);
}
const existingUser = await tx.user.findUnique({ where: { email } });
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profileId } },
})))
)
throw new Error(
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
);
const user =
existingUser ??
(await tx.user.create({
data: {
email,
firstName: profile.educator.firstName,
lastName: profile.educator.lastName,
passwordHash: "",
role: UserRole.ERZIEHER,
kitaId: actor.kitaId,
},
}));
await tx.staffProfile.update({
where: { id: profileId },
data: { userId: user.id },
});
await history("Account verknüpft", email);
if (user.emailVerifiedAt) return {};
const token = randomUUID();
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.verificationToken.create({
data: {
identifier: user.id,
token,
expires: new Date(Date.now() + 7 * 86400000),
},
});
const kita = await tx.kita.findUniqueOrThrow({
where: { id: actor.kitaId },
select: { name: true },
});
const job = await tx.staffEmail.create({
data: {
profileId,
userId: user.id,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
message: "Account aktivieren",
path: `/invite/${token}`,
inviteToken: token,
},
});
return {
emailIds: [job.id],
invite: {
email,
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
token,
kitaName: kita.name,
},
};
return inviteStaff(tx, actor, profile, email);
}
if (command === "pattern") {
requireAdmin(actor);