feat: create and invite educators in a single form
This commit is contained in:
+221
-70
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user