Merge main with notdienst planning and notification improvements
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { hashToken, newToken } from "@/lib/tokens";
|
||||
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 || user.passwordHash || user.disabledAt)
|
||||
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 = newToken();
|
||||
const expiresAt = new Date(Date.now() + INVITE_LIFETIME_MS);
|
||||
await tx.verificationToken.deleteMany({ where: { identifier: userId } });
|
||||
await tx.verificationToken.create({
|
||||
data: { identifier: userId, token: hashToken(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: hashToken(job.token) },
|
||||
});
|
||||
const user = job.user;
|
||||
if (
|
||||
!user ||
|
||||
user.kitaId !== job.kitaId ||
|
||||
!user.familyId ||
|
||||
user.email !== job.email ||
|
||||
user.emailVerifiedAt ||
|
||||
user.disabledAt ||
|
||||
user.passwordHash !== "" ||
|
||||
!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,
|
||||
expectedEmail?: string,
|
||||
) {
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
const actor = await tx.user.findFirst({
|
||||
where: { id: actorId, kitaId, role: "ADMIN", disabledAt: null },
|
||||
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 || user.passwordHash || user.disabledAt)
|
||||
return { error: "Keine offene Einladung gefunden." };
|
||||
if (expectedEmail && user.email !== expectedEmail)
|
||||
return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." };
|
||||
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: hashToken(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.",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user