From 8b33242db119b45f44e52544d795f4c319f0800a Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 18 Oct 2026 10:35:07 +0200 Subject: [PATCH] feat: mail telegram and cron check --- app/api/cron/check/route.ts | 98 +++++++++++++++++++++++++++++++++++++ lib/mail.ts | 10 ++++ lib/notify-text.ts | 10 ++++ lib/scheduler.ts | 35 +++++++++++++ lib/telegram.ts | 10 ++++ tests/notify.test.ts | 17 +++++++ 6 files changed, 180 insertions(+) create mode 100644 app/api/cron/check/route.ts create mode 100644 lib/mail.ts create mode 100644 lib/notify-text.ts create mode 100644 lib/scheduler.ts create mode 100644 lib/telegram.ts create mode 100644 tests/notify.test.ts diff --git a/app/api/cron/check/route.ts b/app/api/cron/check/route.ts new file mode 100644 index 0000000..153a4c3 --- /dev/null +++ b/app/api/cron/check/route.ts @@ -0,0 +1,98 @@ +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"; +} + +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) { + // Unique vor Versand prüfen (Idempotenz: kein Doppelversand). + if (await alreadySent(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 }); +} diff --git a/lib/mail.ts b/lib/mail.ts new file mode 100644 index 0000000..d9ebceb --- /dev/null +++ b/lib/mail.ts @@ -0,0 +1,10 @@ +import nodemailer from "nodemailer"; + +export async function sendMail(to: string, subject: string, text: string): Promise { + const t = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT ?? 587), + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + }); + await t.sendMail({ from: process.env.MAIL_FROM, to, subject, text }); +} diff --git a/lib/notify-text.ts b/lib/notify-text.ts new file mode 100644 index 0000000..bb046fd --- /dev/null +++ b/lib/notify-text.ts @@ -0,0 +1,10 @@ +import { formatEuro } from "./vouchers"; + +function formatDatum(d: string | Date): string { + if (typeof d === "string") return d; + return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +export function buildReminderText(v: { name: string; restCents: number; ablaufdatum: string | Date }): string { + return `Gutschein "${v.name}" läuft am ${formatDatum(v.ablaufdatum)} ab. Restguthaben: ${formatEuro(v.restCents)}.`; +} diff --git a/lib/scheduler.ts b/lib/scheduler.ts new file mode 100644 index 0000000..0987465 --- /dev/null +++ b/lib/scheduler.ts @@ -0,0 +1,35 @@ +// Ruft die Cron-Route alle 15 Minuten im Production-Prozess auf. +// Fehler werden nur geloggt; der nächste Intervall versucht es erneut. +let timer: ReturnType | null = null; + +export function startScheduler(baseUrl: string, intervalMs = 15 * 60 * 1000): () => void { + if (timer) return stopScheduler; + const secret = process.env.CRON_SECRET ?? ""; + const run = async () => { + try { + const res = await fetch(`${baseUrl}/api/cron/check`, { + headers: { "x-cron-secret": secret }, + }); + if (!res.ok) console.error(`cron check failed: ${res.status}`); + } catch (e) { + console.error("cron check error", e); + } + }; + void run(); + timer = setInterval(() => void run(), intervalMs); + return stopScheduler; +} + +export function stopScheduler(): void { + if (timer) { + clearInterval(timer); + timer = null; + } +} + +export function ensureSchedulerStarted(): void { + if (process.env.NODE_ENV !== "production") return; + if (timer) return; + const baseUrl = process.env.APP_URL ?? `http://localhost:${process.env.PORT ?? 3000}`; + startScheduler(baseUrl); +} diff --git a/lib/telegram.ts b/lib/telegram.ts new file mode 100644 index 0000000..dbac6e1 --- /dev/null +++ b/lib/telegram.ts @@ -0,0 +1,10 @@ +export async function sendTelegram(chatId: string, text: string): Promise { + const token = process.env.TELEGRAM_BOT_TOKEN; + if (!token) throw new Error("TELEGRAM_BOT_TOKEN fehlt"); + const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chat_id: chatId, text }), + }); + if (!res.ok) throw new Error(`Telegram ${res.status}`); +} diff --git a/tests/notify.test.ts b/tests/notify.test.ts new file mode 100644 index 0000000..41b5142 --- /dev/null +++ b/tests/notify.test.ts @@ -0,0 +1,17 @@ +// tests/notify.test.ts +import { describe, it, expect, vi } from "vitest"; +describe("notify message", () => { + it("baut Erinnerungstext mit Restbetrag", async () => { + const { buildReminderText } = await import("../lib/notify-text"); + expect(buildReminderText({ name: "Therme", restCents: 2550, ablaufdatum: "01.12.2026" })).toContain("25,50"); + }); + it("sendTelegram ruft Bot-API mit chat_id", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }); + vi.stubGlobal("fetch", fetchMock); + const { sendTelegram } = await import("../lib/telegram"); + process.env.TELEGRAM_BOT_TOKEN = "test-token"; + await sendTelegram("123", "hallo"); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("test-token"), expect.anything()); + vi.unstubAllGlobals(); + }); +});