133 lines
4.7 KiB
TypeScript
133 lines
4.7 KiB
TypeScript
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<typeof getDb>, 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<typeof getDb>,
|
|
ruleId: number,
|
|
kanal: string,
|
|
empfaenger: string,
|
|
zielDatum: string
|
|
): Promise<boolean> {
|
|
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<typeof getDb>,
|
|
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 });
|
|
}
|