import { randomUUID } from "node:crypto"; import { createElement } from "react"; import type { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { serializableTransaction } from "./transaction"; import { UserFacingError } from "./action-error"; import { berlinDateKey, formatUtcDateKey, getTargetMonthData, isNotdienstReminderDue, NOTDIENST_LOCK_DAY, } from "./date-utils"; import { loadNotdienstDays } from "./notdienst-planning"; import { sendAppEmail } from "@/lib/mail"; import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail"; import { NotdienstPlanPublishedEmail } from "@/emails/NotdienstPlanPublishedEmail"; type Tx = Prisma.TransactionClient; const parentRoles = ["ELTERN", "ADMIN", "KOORDINATOR"] as const; const eligibleParents = (now: Date): Prisma.UserWhereInput => ({ role: { in: [...parentRoles] }, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null }, disabledAt: null, OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }], }); const monthLabel = (year: number, month: number) => new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", month: "long", year: "numeric", }).format(new Date(Date.UTC(year, month - 1, 1))); const period = (year: number, month: number) => ({ gte: new Date(Date.UTC(year, month - 1, 1)), lt: new Date(Date.UTC(year, month, 1)), }); export async function getFamilyNotdienstProgress( db: Pick, kitaId: string, familyId: string, year: number, month: number, minimum: number, ) { const [children, entries, days] = await Promise.all([ db.child.count({ where: { kitaId, familyId, active: true } }), db.notdienstAvailability.findMany({ where: { kitaId, child: { kitaId, familyId, active: true }, date: period(year, month), }, select: { date: true }, }), loadNotdienstDays(db, kitaId, year, month), ]); const valid = new Set(days.map(formatUtcDateKey)); const selected = new Set( entries .map((a) => formatUtcDateKey(a.date)) .filter((key) => valid.has(key)), ).size; return { selected, required: children * minimum }; } export async function queueNotdienstReminders( kitaId: string, year: number, month: number, automatic = false, now = new Date(), ) { return serializableTransaction(async (tx) => { const target = getTargetMonthData(now); if ( target.isLocked || target.targetYear !== year || target.targetMonth !== month ) { if (automatic) return 0; throw new UserFacingError( "Erinnerungen sind nur während der Eintragsfrist für den Folgemonat möglich.", ); } const kita = await tx.kita.findUniqueOrThrow({ where: { id: kitaId }, select: { notdienstModuleEnabled: true, notdienstMinPerChildPerMonth: true, notdienstReminderDaysBefore: true, }, }); if (!kita.notdienstModuleEnabled) { if (automatic) return 0; throw new UserFacingError("Das Notdienstmodul ist deaktiviert."); } const plan = await tx.notdienstPlan.findUnique({ where: { kitaId_year_month: { kitaId, year, month } }, select: { status: true }, }); if (plan?.status === "PUBLISHED") return 0; if (automatic) { if (!isNotdienstReminderDue(kita.notdienstReminderDaysBefore, now)) return 0; const run = await tx.notdienstReminderRun.createMany({ data: [{ id: randomUUID(), kitaId, year, month, createdAt: now }], skipDuplicates: true, }); if (!run.count) return 0; } const [families, entries, days] = await Promise.all([ tx.family.findMany({ where: { kitaId, children: { some: { kitaId, active: true } } }, select: { id: true, children: { where: { kitaId, active: true }, select: { id: true } }, users: { where: { kitaId, ...eligibleParents(now) }, select: { id: true }, }, }, }), tx.notdienstAvailability.findMany({ where: { kitaId, date: period(year, month), child: { kitaId, active: true }, }, select: { date: true, child: { select: { familyId: true } } }, }), loadNotdienstDays(tx, kitaId, year, month), ]); const valid = new Set(days.map(formatUtcDateKey)); const selected = new Map>(); for (const entry of entries) { const key = formatUtcDateKey(entry.date); if (!valid.has(key)) continue; const dates = selected.get(entry.child.familyId) ?? new Set(); dates.add(key); selected.set(entry.child.familyId, dates); } const jobs = families .filter( (family) => (selected.get(family.id)?.size ?? 0) < family.children.length * kita.notdienstMinPerChildPerMonth, ) .flatMap((family) => family.users.map((user) => ({ id: randomUUID(), kitaId, familyId: family.id, userId: user.id, year, month, kind: "REMINDER", eventKey: `reminder:${berlinDateKey(now)}`, createdAt: now, nextAttemptAt: now, })), ); if (!jobs.length) return 0; return ( await tx.notdienstEmail.createMany({ data: jobs, skipDuplicates: true }) ).count; }); } // Must run in the same transaction as the first publication. export async function queueNotdienstPublication( tx: Tx, kitaId: string, planId: string, year: number, month: number, now = new Date(), ) { const assigned = await tx.notdienstAssignment.findMany({ where: { kitaId, planId, child: { kitaId, active: true } }, select: { child: { select: { familyId: true } } }, }); const families = await tx.family.findMany({ where: { kitaId, id: { in: [...new Set(assigned.map((a) => a.child.familyId))] }, }, select: { id: true, users: { where: { kitaId, ...eligibleParents(now) }, select: { id: true }, }, }, }); const jobs = families.flatMap((family) => family.users.map((user) => ({ id: randomUUID(), kitaId, familyId: family.id, userId: user.id, planId, year, month, kind: "PUBLICATION", eventKey: `publication:${planId}`, createdAt: now, nextAttemptAt: now, })), ); return jobs.length ? (await tx.notdienstEmail.createMany({ data: jobs, skipDuplicates: true })) .count : 0; } export async function deliverNotdienstEmails( ids: string[], send = sendAppEmail, now = new Date(), ) { const result = { sent: 0, failed: 0, skipped: 0 }; const deliver = async (id: string) => { const claimToken = randomUUID(); const leaseUntil = new Date(now.getTime() + 5 * 60_000); const claim = await prisma.notdienstEmail.updateMany({ where: { id, sentAt: null, skippedAt: null, nextAttemptAt: { lte: now }, OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }], }, data: { claimToken, leaseUntil, attempts: { increment: 1 } }, }); if (!claim.count) return; const finish = (data: Prisma.NotdienstEmailUpdateManyMutationInput) => prisma.notdienstEmail.updateMany({ where: { id, claimToken }, data: { ...data, claimToken: null, leaseUntil: null }, }); try { const job = await prisma.notdienstEmail.findUniqueOrThrow({ where: { id }, include: { user: true, kita: true, plan: { select: { status: true } } }, }); const { user, kita } = job; let eligible = kita.notdienstModuleEnabled && user.kitaId === job.kitaId && user.familyId === job.familyId && parentRoles.some((role) => role === user.role) && !!user.emailVerifiedAt && !!user.privacyPolicyAcceptedAt && !user.disabledAt && (!user.lockedUntil || user.lockedUntil <= now); let progress: { selected: number; required: number } | undefined; let dates: string[] = []; if (job.kind === "REMINDER") { const target = getTargetMonthData(now); const plan = await prisma.notdienstPlan.findUnique({ where: { kitaId_year_month: { kitaId: job.kitaId, year: job.year, month: job.month, }, }, select: { status: true }, }); eligible &&= !target.isLocked && target.targetYear === job.year && target.targetMonth === job.month && plan?.status !== "PUBLISHED"; if (eligible) { progress = await getFamilyNotdienstProgress( prisma, job.kitaId, job.familyId, job.year, job.month, kita.notdienstMinPerChildPerMonth, ); eligible &&= progress.required > 0 && progress.selected < progress.required; } } else if (job.kind === "PUBLICATION") { eligible &&= !!job.planId && job.plan?.status === "PUBLISHED"; if (eligible) { const [assignments, days] = await Promise.all([ prisma.notdienstAssignment.findMany({ where: { kitaId: job.kitaId, planId: job.planId!, child: { kitaId: job.kitaId, familyId: job.familyId, active: true, }, date: { ...period(job.year, job.month), }, }, select: { date: true }, orderBy: { date: "asc" }, }), loadNotdienstDays(prisma, job.kitaId, job.year, job.month), ]); const valid = new Set(days.map(formatUtcDateKey)); dates = assignments .map((a) => formatUtcDateKey(a.date)) .filter((key) => key >= berlinDateKey(now) && valid.has(key)); eligible &&= dates.length > 0; } } else eligible = false; if (!eligible) { await finish({ skippedAt: now, failed: false, lastError: null }); result.skipped++; 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 ( !["http:", "https:"].includes(site.protocol) || site.username || site.password ) throw new Error("App-Adresse ungültig."); const label = monthLabel(job.year, job.month); const link = new URL( job.kind === "REMINDER" ? "/dashboard/notdienst" : "/dashboard/kalender", site.origin, ).toString(); const response = await send({ to: user.email, idempotencyKey: `notdienst-email/${job.id}`, subject: job.kind === "REMINDER" ? `Erinnerung: Notdienst-Verfügbarkeiten für ${label}` : `Notdienstplan ${label} veröffentlicht`, react: job.kind === "REMINDER" ? createElement(NotdienstAvailabilityReminderEmail, { parentName: user.firstName, kitaName: kita.name, monthLabel: label, link, selectedDays: progress!.selected, requiredDays: progress!.required, deadlineLabel: new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", day: "2-digit", month: "2-digit", year: "numeric", }).format( new Date( Date.UTC(job.year, job.month - 2, NOTDIENST_LOCK_DAY), ), ), }) : createElement(NotdienstPlanPublishedEmail, { parentName: user.firstName, kitaName: kita.name, monthLabel: label, dateLabels: dates.map((key) => new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", weekday: "long", day: "2-digit", month: "2-digit", year: "numeric", }).format(new Date(`${key}T00:00:00Z`)), ), link, }), }); if (!response.success) throw new Error(response.error); await finish({ sentAt: now, failed: false, lastError: null }); result.sent++; } catch { const current = await prisma.notdienstEmail.findUnique({ where: { id }, select: { attempts: true }, }); const delay = Math.min( 6 * 60 * 60_000, 60_000 * 2 ** Math.min(current?.attempts ?? 1, 8), ); await finish({ failed: true, lastError: "E-Mail-Versand fehlgeschlagen. Der Versand wird erneut versucht.", nextAttemptAt: new Date(now.getTime() + delay), }); result.failed++; } }; const unique = [...new Set(ids)]; for (let i = 0; i < unique.length; i += 4) await Promise.all(unique.slice(i, i + 4).map(deliver)); return result; } type MailScope = { kitaId?: string; year?: number; month?: number; planId?: string; }; export async function deliverDueNotdienstEmails( scope: MailScope = {}, send = sendAppEmail, now = new Date(), ) { const jobs = await prisma.notdienstEmail.findMany({ where: { ...scope, sentAt: null, skippedAt: null, nextAttemptAt: { lte: now }, OR: [{ leaseUntil: null }, { leaseUntil: { lte: now } }], }, select: { id: true }, orderBy: [{ nextAttemptAt: "asc" }, { createdAt: "asc" }], take: 12, }); return deliverNotdienstEmails( jobs.map((job) => job.id), send, now, ); } export async function runNotdienstNotificationJob( send = sendAppEmail, now = new Date(), kitaId?: string, ) { const target = getTargetMonthData(now); let queued = 0; if (!target.isLocked && isNotdienstReminderDue(31, now)) { const candidates = await prisma.kita.findMany({ where: { ...(kitaId ? { id: kitaId } : {}), notdienstModuleEnabled: true, notdienstPlans: { none: { year: target.targetYear, month: target.targetMonth, status: "PUBLISHED" } }, notdienstReminderDaysBefore: { gte: Math.max( 1, NOTDIENST_LOCK_DAY - Number(berlinDateKey(now).slice(-2)), ), }, notdienstReminderRuns: { none: { year: target.targetYear, month: target.targetMonth }, }, }, select: { id: true, notdienstReminderDaysBefore: true }, orderBy: { id: "asc" }, take: 100, }); for (const kita of candidates) if (isNotdienstReminderDue(kita.notdienstReminderDaysBefore, now)) queued += await queueNotdienstReminders( kita.id, target.targetYear, target.targetMonth, true, now, ); } return { queued, ...(await deliverDueNotdienstEmails(kitaId ? { kitaId } : {}, send, now)), }; } export async function getNotdienstEmailStatus( kitaId: string, year: number, month: number, ) { const jobs = await prisma.notdienstEmail.findMany({ where: { kitaId, year, month }, select: { id: true, kind: true, sentAt: true, skippedAt: true, failed: true, lastError: true, user: { select: { firstName: true, lastName: true } }, family: { select: { name: true } }, }, orderBy: { createdAt: "desc" }, }); const status = (job: (typeof jobs)[number]) => job.sentAt ? ("sent" as const) : job.skippedAt ? ("skipped" as const) : job.failed ? ("failed" as const) : ("pending" as const); return { sent: jobs.filter((job) => status(job) === "sent").length, failed: jobs.filter((job) => status(job) === "failed").length, pending: jobs.filter((job) => status(job) === "pending").length, skipped: jobs.filter((job) => status(job) === "skipped").length, entries: jobs.slice(0, 30).map((job) => ({ id: job.id, kind: job.kind, status: status(job), name: `${job.user.firstName} ${job.user.lastName}`, familyName: job.family.name, error: job.lastError, })), }; } export type NotdienstEmailStatus = Awaited< ReturnType >;