225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
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.",
|
|
}
|
|
: {}),
|
|
};
|
|
}
|