import { NextRequest, NextResponse } from "next/server"; import { getDb } from "@/lib/db"; import { dueRulesToday, toDateKey } from "@/lib/reminders"; import { buildReminderText } from "@/lib/notify-text"; import { sendMail } from "@/lib/mail"; import { sendTelegram } from "@/lib/telegram"; async function alreadySent(db: ReturnType, ruleId: number, kanal: string, empfaenger: string, zielDatum: string) { const found = await db.notificationLog.findUnique({ where: { ruleId_kanal_empfaenger_zielDatum: { ruleId, kanal, empfaenger, zielDatum } }, }); return found?.status === "gesendet" || found?.status === "versendet"; } // Atomarer Claim vor dem Versand: erster Gewinner legt eine "versendet"-Zeile an, // ein Konkurrent mit Unique-Konflikt (P2002) wird abgewiesen. "fehler" bleibt retrybar. async function claimSlot( db: ReturnType, ruleId: number, kanal: string, empfaenger: string, zielDatum: string ): Promise { try { await db.notificationLog.create({ data: { ruleId, kanal, empfaenger, zielDatum, status: "versendet" }, }); return true; } catch (e) { if (typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "P2002") { const existing = await db.notificationLog.findUnique({ where: { ruleId_kanal_empfaenger_zielDatum: { ruleId, kanal, empfaenger, zielDatum } }, }); // Fehler-Logs erneut versuchen: auf pending zurücksetzen und senden. if (existing?.status === "fehler") { await db.notificationLog.update({ where: { id: existing.id }, data: { status: "versendet", fehler: null }, }); return true; } return false; } throw e; } } async function logResult( db: ReturnType, ruleId: number, kanal: string, empfaenger: string, zielDatum: string, ok: boolean, fehler?: string ) { const status = ok ? "gesendet" : "fehler"; const existing = await db.notificationLog.findUnique({ where: { ruleId_kanal_empfaenger_zielDatum: { ruleId, kanal, empfaenger, zielDatum } }, }); if (existing) { // Retry übers nächstes Intervall: Fehler-Logs werden beim nächsten Cron-Lauf erneut versucht. await db.notificationLog.update({ where: { id: existing.id }, data: { status, fehler: fehler ?? null }, }); } else { await db.notificationLog.create({ data: { ruleId, kanal, empfaenger, zielDatum, status, fehler: fehler ?? null }, }); } } export async function GET(req: NextRequest) { const secret = process.env.CRON_SECRET; if (!secret) return NextResponse.json({ error: "CRON_SECRET fehlt" }, { status: 500 }); if (req.headers.get("x-cron-secret") !== secret) { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } const db = getDb(); const heute = new Date(); const vouchers = await db.voucher.findMany({ where: { status: "aktiv" }, include: { rules: true, contacts: { include: { contact: true } } }, }); let checked = 0; let sent = 0; let failed = 0; for (const v of vouchers) { if (!v.ablaufdatum) continue; const due = dueRulesToday(v, v.rules, heute); if (!due.length) continue; checked++; const zielDatum = toDateKey(new Date(v.ablaufdatum)); const datumAnzeige = new Date(v.ablaufdatum).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", }); const text = buildReminderText({ name: v.name, restCents: v.restCents, ablaufdatum: datumAnzeige }); for (const ruleId of due) { for (const vc of v.contacts) { const targets: { kanal: string; empfaenger: string }[] = []; if (vc.contact.email) targets.push({ kanal: "mail", empfaenger: vc.contact.email }); if (vc.contact.telegramChatId) targets.push({ kanal: "telegram", empfaenger: vc.contact.telegramChatId }); for (const t of targets) { // Atomar claimen vor Versand (Idempotenz: kein Doppelversand bei parallelen Läufen). if (await alreadySent(db, ruleId, t.kanal, t.empfaenger, zielDatum)) continue; if (!(await claimSlot(db, ruleId, t.kanal, t.empfaenger, zielDatum))) continue; try { if (t.kanal === "mail") { await sendMail(t.empfaenger, `Gutschein "${v.name}" läuft bald ab`, text); } else { await sendTelegram(t.empfaenger, text); } await logResult(db, ruleId, t.kanal, t.empfaenger, zielDatum, true); sent++; } catch (e) { await logResult(db, ruleId, t.kanal, t.empfaenger, zielDatum, false, e instanceof Error ? e.message : String(e)); failed++; } } } } } return NextResponse.json({ checked, sent, failed }); }