feat: mail telegram and cron check
This commit is contained in:
1 parent
6e1d0a7568
commit
8b33242db1
6 files changed
+180
No files matched your search
@@ -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<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";
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
export async function sendMail(to: string, subject: string, text: string): Promise<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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)}.`;
|
||||||
|
}
|
||||||
@@ -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<typeof setInterval> | 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export async function sendTelegram(chatId: string, text: string): Promise<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user