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
+37
View File
@@ -0,0 +1,37 @@
// Absences are calendar dates stored as UTC dates, independent of the server timezone.
export function absenceDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Ungültiges Datum.");
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value)
throw new Error("Ungültiges Datum.");
return date;
}
export function isAbsenceDate(value: string) {
try {
absenceDate(value);
return true;
} catch {
return false;
}
}
export function absenceToday(now = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(now);
const part = (type: string) =>
parts.find((item) => item.type === type)!.value;
return `${part("year")}-${part("month")}-${part("day")}`;
}
export function canManageKitaAbsences(role: string) {
return ["ADMIN", "KOORDINATOR", "ERZIEHER"].includes(role);
}
export function canEditOwnAbsence(startDate: string, today: string) {
return startDate > today;
}
+36
View File
@@ -0,0 +1,36 @@
export type FamilyInvitationView = {
status: "active" | "open" | "sending" | "sent" | "failed" | "expired";
expiresAt: string | null;
sentAt: string | null;
};
export function familyInvitationView(
emailVerifiedAt: Date | null,
invitation: {
expiresAt: Date;
sentAt: Date | null;
failed: boolean;
leaseUntil: Date | null;
status: string;
} | null,
now = new Date(),
): FamilyInvitationView {
let status: FamilyInvitationView["status"] = "open";
if (emailVerifiedAt) status = "active";
else if (invitation) {
if (
invitation.expiresAt <= now ||
["EXPIRED", "REVOKED"].includes(invitation.status)
)
status = "expired";
else if (invitation.leaseUntil && invitation.leaseUntil > now)
status = "sending";
else if (invitation.failed) status = "failed";
else if (invitation.sentAt) status = "sent";
}
return {
status,
expiresAt: invitation?.expiresAt.toISOString() ?? null,
sentAt: invitation?.sentAt?.toISOString() ?? null,
};
}
+219
View File
@@ -0,0 +1,219 @@
import { randomUUID } from "node:crypto";
import { createElement } from "react";
import { InvitationStatus, Prisma } from "@prisma/client";
import { InviteEmail } from "@/emails/InviteEmail";
import { sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
const INVITE_LIFETIME_MS = 7 * 24 * 60 * 60_000;
// The account, activation token and delivery status commit together.
export async function queueFamilyInvitation(
tx: Prisma.TransactionClient,
userId: string,
invitedById: string,
) {
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
if (!user.kitaId || !user.familyId || user.emailVerifiedAt)
throw new Error("Dieses Konto benötigt keine Familieneinladung.");
const existing = await tx.invitation.findFirst({
where: {
OR: [{ userId }, { kitaId: user.kitaId, email: user.email }],
},
});
if (existing?.userId && existing.userId !== userId)
throw new Error("Die Einladung gehört zu einem anderen Konto.");
if (existing?.leaseUntil && existing.leaseUntil > new Date())
throw new Error("Eine Einladung wird gerade versendet. Bitte kurz warten.");
const token = randomUUID();
const expiresAt = new Date(Date.now() + INVITE_LIFETIME_MS);
await tx.verificationToken.deleteMany({ where: { identifier: userId } });
await tx.verificationToken.create({
data: { identifier: userId, token, expires: expiresAt },
});
const data = {
userId,
kitaId: user.kitaId,
email: user.email,
role: user.role,
token,
expiresAt,
invitedById,
status: InvitationStatus.PENDING,
acceptedAt: null,
sentAt: null,
lastAttemptAt: null,
leaseUntil: null,
attempts: 0,
failed: false,
};
return existing
? tx.invitation.update({ where: { id: existing.id }, data })
: tx.invitation.create({ data });
}
// Tests inject a transport. Every recipient is attempted despite individual failures.
export async function deliverFamilyInvitations(
ids: string[],
send = sendAppEmail,
) {
let sent = 0;
let failed = 0;
for (const id of new Set(ids)) {
const now = new Date();
const leaseUntil = new Date(now.getTime() + 5 * 60_000);
const claim = await prisma.invitation.updateMany({
where: {
id,
userId: { not: null },
status: InvitationStatus.PENDING,
sentAt: null,
expiresAt: { gt: now },
OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }],
},
data: { leaseUntil, lastAttemptAt: now, attempts: { increment: 1 } },
});
if (!claim.count) continue;
const finish = (data: Prisma.InvitationUpdateManyMutationInput) =>
prisma.invitation.updateMany({
where: { id, token: jobToken, leaseUntil },
data: { ...data, leaseUntil: null },
});
let jobToken: string | undefined;
try {
const job = await prisma.invitation.findUniqueOrThrow({
where: { id },
include: { user: true, kita: { select: { name: true } } },
});
jobToken = job.token;
const token = await prisma.verificationToken.findUnique({
where: { token: job.token },
});
const user = job.user;
if (
!user ||
user.kitaId !== job.kitaId ||
!user.familyId ||
user.email !== job.email ||
user.emailVerifiedAt ||
!token ||
token.identifier !== user.id ||
token.expires <= now
) {
await finish({
status: user?.emailVerifiedAt
? InvitationStatus.ACCEPTED
: InvitationStatus.REVOKED,
failed: false,
});
continue;
}
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 Einladungen fehlt.");
const site = new URL(base);
if (
!["https:", "http:"].includes(site.protocol) ||
site.username ||
site.password
)
throw new Error("App-Adresse für Einladungen ist ungültig.");
const result = await send({
to: job.email,
subject: `Einladung zu ${job.kita.name} im Kita-Planer`,
idempotencyKey: `family-invitation/${job.token}`,
react: createElement(InviteEmail, {
parentName: `${user.firstName} ${user.lastName}`,
kitaName: job.kita.name,
inviteLink: new URL(`/invite/${job.token}`, site.origin).toString(),
}),
});
if (result.success) {
await finish({ sentAt: new Date(), failed: false });
sent++;
} else {
await finish({ failed: true });
failed++;
}
} catch {
await finish({ failed: true });
failed++;
}
}
return { sent, failed };
}
export async function resendFamilyInvitation(
kitaId: string,
userId: string,
actorId: string,
) {
const outcome = await prisma.$transaction(async (tx) => {
const actor = await tx.user.findFirst({
where: { id: actorId, kitaId, role: "ADMIN" },
select: { id: true },
});
if (!actor)
return {
error: "Nur Kita-Admins dürfen Familieneinladungen erneut senden.",
};
// Serializes renewal with other renewals for this account.
await tx.$queryRaw`SELECT "id" FROM "users" WHERE "id" = ${userId} AND "kitaId" = ${kitaId} FOR UPDATE`;
const user = await tx.user.findFirst({
where: { id: userId, kitaId, familyId: { not: null } },
});
if (!user) return { error: "Elternkonto wurde nicht gefunden." };
if (user.emailVerifiedAt)
return { error: "Dieses Konto ist bereits aktiviert." };
const existing = await tx.invitation.findUnique({ where: { userId } });
const now = new Date();
if (existing?.leaseUntil && existing.leaseUntil > now)
return {
error: "Die Einladung wird gerade versendet. Bitte kurz warten.",
};
if (existing?.sentAt && now.getTime() - existing.sentAt.getTime() < 60_000)
return {
error:
"Die Einladung wurde gerade versendet. Bitte nach einer Minute erneut versuchen.",
};
const token = existing
? await tx.verificationToken.findUnique({
where: { token: existing.token },
})
: null;
if (
existing &&
existing.status === InvitationStatus.PENDING &&
!existing.sentAt &&
existing.email === user.email &&
existing.expiresAt > now &&
token?.identifier === user.id &&
token.expires > now
)
return { id: existing.id };
const invitation = await queueFamilyInvitation(tx, userId, actorId);
return { id: invitation.id };
});
if (outcome.error || !outcome.id)
return {
error: outcome.error ?? "Einladung konnte nicht erstellt werden.",
};
const result = await deliverFamilyInvitations([outcome.id]);
return {
success: true,
...(result.failed
? {
warning:
"Einladung ist gespeichert, konnte aber nicht versendet werden. Du kannst den Versand erneut versuchen.",
}
: !result.sent
? {
warning:
"Die Einladung ist vorbereitet. Bitte prüfe den aktuellen Versandstatus.",
}
: {}),
};
}