Add educator email invitations and staff notification preferences

This commit is contained in:
t.indorf
2026-10-08 11:32:38 +02:00
parent 043fed2e46
commit 90b8843cda
15 changed files with 700 additions and 39 deletions
+12 -7
View File
@@ -9,6 +9,7 @@ type SendAppEmailInput = {
react: ReactNode;
from?: string;
replyTo?: string | string[];
idempotencyKey?: string;
};
type SendAppEmailResult =
@@ -36,6 +37,7 @@ export async function sendAppEmail({
react,
from = process.env.EMAIL_FROM,
replyTo,
idempotencyKey,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (!process.env.RESEND_API_KEY) {
if (process.env.NODE_ENV === "development") {
@@ -63,13 +65,16 @@ export async function sendAppEmail({
try {
resendClient ??= new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resendClient.emails.send({
from: from!,
to,
subject,
react,
replyTo,
});
const { data, error } = await resendClient.emails.send(
{
from: from!,
to,
subject,
react,
replyTo,
},
{ idempotencyKey },
);
if (error) {
console.error("Resend konnte die E-Mail nicht versenden:", error);
+115
View File
@@ -0,0 +1,115 @@
import { createElement } from "react";
import { prisma } from "@/lib/prisma";
import { sendAppEmail } from "@/lib/mail";
import { InviteEmail } from "@/emails/InviteEmail";
import { StaffNotificationEmail } from "@/emails/StaffNotificationEmail";
// Called after the staff transaction commits. Tests inject a transport and never send mail.
export async function deliverStaffEmails(ids: string[], send = sendAppEmail) {
let failed = 0;
let sent = 0;
const deliver = async (id: string) => {
const now = new Date();
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
const claim = await prisma.staffEmail.updateMany({
where: {
id,
sentAt: null,
skippedAt: null,
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
data: { leaseUntil, attempts: { increment: 1 } },
});
if (!claim.count) return;
const finish = (data: {
sentAt?: Date;
skippedAt?: Date;
failed: boolean;
}) =>
prisma.staffEmail.updateMany({
where: { id, leaseUntil },
data: { ...data, leaseUntil: null },
});
try {
const job = await prisma.staffEmail.findUniqueOrThrow({
where: { id },
include: {
user: true,
profile: { include: { kita: true, educator: true } },
},
});
const { user, profile } = job;
let eligible = user.kitaId === profile.kitaId;
if (job.inviteToken) {
const token = await prisma.verificationToken.findUnique({
where: { token: job.inviteToken },
});
eligible &&=
profile.userId === user.id &&
user.role === "ERZIEHER" &&
!user.emailVerifiedAt &&
!!token &&
token.identifier === user.id &&
token.expires > now;
} else {
eligible &&=
!!user.privacyPolicyAcceptedAt &&
(!user.lockedUntil || user.lockedUntil <= now) &&
(job.toAdmins
? user.role === "ADMIN"
: user.role === "ERZIEHER" &&
profile.userId === user.id &&
user.staffDecisionEmails);
}
if (!eligible) {
await finish({ skippedAt: now, failed: false });
return;
}
const base =
process.env.NEXT_PUBLIC_SITE_URL ||
process.env.AUTH_URL ||
process.env.NEXTAUTH_URL;
if (!base) throw new Error("App-Adresse für E-Mails fehlt.");
const site = new URL(base);
if (
!["https:", "http:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error("App-Adresse für E-Mails ist ungültig.");
const link = new URL(job.path, site.origin).toString();
const result = await send({
to: user.email,
subject: job.subject,
idempotencyKey: `staff-email/${job.id}`,
react: job.inviteToken
? createElement(InviteEmail, {
parentName: `${profile.educator.firstName} ${profile.educator.lastName}`,
kitaName: profile.kita.name,
inviteLink: link,
staffAccount: true,
})
: createElement(StaffNotificationEmail, {
name: user.firstName,
kitaName: profile.kita.name,
message: job.message,
link,
}),
});
if (!result.success) {
await finish({ failed: true });
failed++;
} else {
await finish({ sentAt: new Date(), failed: false });
sent++;
}
} catch {
await finish({ failed: true });
failed++;
}
};
const uniqueIds = [...new Set(ids)];
for (let offset = 0; offset < uniqueIds.length; offset += 4)
await Promise.all(uniqueIds.slice(offset, offset + 4).map(deliver));
return { sent, failed };
}
+89 -2
View File
@@ -28,6 +28,7 @@ export type StaffNotice = {
toAdmins: boolean;
};
type CommandResult = {
emailIds?: string[];
notice?: StaffNotice;
invite?: { email: string; name: string; token: string; kitaName: string };
};
@@ -214,6 +215,33 @@ export async function executeStaffCommand(
throw new Error(
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
);
if (command === "emailPreference") {
if (actor.role !== UserRole.ERZIEHER)
throw new Error(
"Diese Einstellung ist für Fachkräfte vorgesehen.",
);
await tx.user.update({
where: { id: actor.id },
data: {
staffDecisionEmails: z.boolean().parse(data.enabled),
},
});
return {};
}
if (command === "emailRetry") {
const jobs = await tx.staffEmail.findMany({
where: {
profile: { kitaId: actor.kitaId },
...(actor.role === UserRole.ADMIN ? {} : { userId: actor.id }),
sentAt: null,
skippedAt: null,
},
select: { id: true },
orderBy: { createdAt: "asc" },
take: 50,
});
return { emailIds: jobs.map((job) => job.id) };
}
if (command === "holiday") {
requireAdmin(actor);
const input = z
@@ -356,6 +384,7 @@ export async function executeStaffCommand(
const notice = async (
text: string,
toAdmins: boolean,
email?: { message: string; query: string },
): Promise<CommandResult> => {
const recipients = await tx.user.findMany({
where: {
@@ -364,7 +393,7 @@ export async function executeStaffCommand(
? { role: UserRole.ADMIN }
: { id: profile.userId ?? "" }),
},
select: { id: true },
select: { id: true, role: true, staffDecisionEmails: true },
});
await tx.staffNotification.createMany({
data: recipients.map((user) => ({
@@ -373,7 +402,28 @@ export async function executeStaffCommand(
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${text}`,
})),
});
return { notice: { profileId, text, toAdmins } };
const emailIds: string[] = [];
if (email) {
for (const user of recipients) {
if (
!toAdmins &&
(user.role !== UserRole.ERZIEHER || !user.staffDecisionEmails)
)
continue;
const job = await tx.staffEmail.create({
data: {
profileId,
userId: user.id,
toAdmins,
subject: "Kita-Planer: Hinweis im Personalbereich",
message: `${profile.educator.firstName} ${profile.educator.lastName}: ${email.message}`,
path: `/dashboard/personal?educator=${encodeURIComponent(profile.educatorId)}&${email.query}`,
},
});
emailIds.push(job.id);
}
}
return { notice: { profileId, text, toAdmins }, emailIds };
};
if (command === "noticeRead") {
await tx.staffNotification.updateMany({
@@ -446,7 +496,18 @@ export async function executeStaffCommand(
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}`,
@@ -845,6 +906,10 @@ export async function executeStaffCommand(
return notice(
`Arbeitswoche ab ${start} wurde zur Prüfung eingereicht.`,
true,
{
message: `Arbeitswoche ${start}–${end} wurde zur Prüfung eingereicht.`,
query: `tab=zeit&week=${start}`,
},
);
}
requireAdmin(actor);
@@ -901,6 +966,10 @@ export async function executeStaffCommand(
return notice(
`Arbeitswoche ab ${start}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}. ${input.reason}`,
false,
{
message: `Arbeitswoche ${start}–${end}: ${input.approve ? "genehmigt" : "zur Korrektur zurückgegeben"}.`,
query: `tab=zeit&week=${start}`,
},
);
}
if (
@@ -937,6 +1006,12 @@ export async function executeStaffCommand(
? "Ein offener Abwesenheitsantrag wurde zurückgezogen."
: "Die Stornierung einer genehmigten Abwesenheit wurde beantragt.",
true,
existing.kind === "SICKNESS"
? undefined
: {
message: `Abwesenheitsantrag ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "zurückgezogen" : "Stornierung beantragt"}.`,
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
},
);
}
if (command === "leaveReview") {
@@ -1032,6 +1107,12 @@ export async function executeStaffCommand(
return notice(
`${existing.kind === "VACATION" ? "Urlaub" : existing.kind === "TIME_OFF" ? "Freizeitausgleich" : "Krankmeldung"}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : "genehmigt"}. ${input.reason}`,
false,
existing.kind === "SICKNESS"
? undefined
: {
message: `${existing.kind === "VACATION" ? "Urlaub" : "Freizeitausgleich"} ${dateKey(existing.startDate)}–${dateKey(existing.endDate)}: ${status === "CANCELLED" ? "storniert" : status === "REJECTED" ? "abgelehnt" : existing.status === "CANCEL_REQUESTED" ? "Stornierung abgelehnt; Genehmigung bleibt bestehen" : "genehmigt"}.`,
query: `tab=urlaub&year=${existing.startDate.getUTCFullYear()}`,
},
);
}
if (!profile.educator.active)
@@ -1140,6 +1221,12 @@ export async function executeStaffCommand(
return notice(
`${input.kind === "SICKNESS" ? "Krankheit wurde hinterlegt" : "Ein Abwesenheitsantrag wurde eingereicht"}: ${input.startDate}–${input.endDate}.`,
input.kind !== "SICKNESS",
input.kind === "SICKNESS"
? undefined
: {
message: `${input.kind === "VACATION" ? "Urlaubsantrag" : "Antrag auf Freizeitausgleich"} ${input.startDate}–${input.endDate} wurde ${existing ? "geändert" : "eingereicht"}.`,
query: `tab=urlaub&year=${input.startDate.slice(0, 4)}`,
},
);
}
throw new Error("Unbekannte Aktion.");