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.", } : {}), }; }