diff --git a/backend/package.json b/backend/package.json index 535e176..77a3efa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "dunning:run": "ts-node -r dotenv/config src/jobs/dailyDunningJob.ts", "cleaning-reminder:run": "ts-node -r dotenv/config src/jobs/cleaningReminderJob.ts", "trash-reminder:run": "ts-node -r dotenv/config src/jobs/trashReminderJob.ts", + "notice-deadline-reminder:run": "ts-node -r dotenv/config src/jobs/noticeDeadlineReminderJob.ts", "test:webhook": "ts-node -r dotenv/config scripts/send-test-webhook.ts", "prisma:generate": "prisma generate --schema=../prisma/schema.prisma", "prisma:migrate": "prisma migrate dev --schema=../prisma/schema.prisma", diff --git a/backend/src/jobs/noticeDeadlineReminderJob.ts b/backend/src/jobs/noticeDeadlineReminderJob.ts new file mode 100644 index 0000000..9532341 --- /dev/null +++ b/backend/src/jobs/noticeDeadlineReminderJob.ts @@ -0,0 +1,85 @@ +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; +import { sendPushNotification } from '../services/notificationService'; + +const prisma = new PrismaClient(); + +const NOTIFICATION_TYPE = 'NOTICE_DEADLINE'; +// Keine Wiederholung innerhalb dieses Zeitraums je Vertrag, um den Vermieter +// nicht täglich 30 Tage lang zu spammen (bewusst kein Extra-Feld auf Contract +// nötig — analog contracts.ts prüfen wir stattdessen den Notification-Verlauf). +const RESEND_COOLDOWN_DAYS = 14; + +function addMonths(date: Date, months: number): Date { + const d = new Date(date); + d.setMonth(d.getMonth() - months); + return d; +} + +/** + * Täglicher Cron-Job (analog dailyDunningJob.ts): warnt Vermieter/Admin per + * Push, wenn bei einem aktiven, befristeten Vertrag die Kündigungsfrist + * innerhalb der nächsten 30 Tage abläuft (isUrgent-Logik aus + * routes/contracts.ts). Max. 1x je Vertrag pro RESEND_COOLDOWN_DAYS. + */ +export async function runNoticeDeadlineReminderJob(referenceDate: Date = new Date()) { + const contracts = await prisma.contract.findMany({ + where: { isActive: true, endDate: { not: null } }, + select: { + id: true, + endDate: true, + noticePeriodMonths: true, + user: { select: { fullName: true } }, + room: { select: { roomNumber: true } }, + }, + }); + + const urgent = contracts.filter((c) => { + const noticeDeadline = addMonths(c.endDate!, c.noticePeriodMonths); + const daysUntil = Math.ceil((noticeDeadline.getTime() - referenceDate.getTime()) / (1000 * 60 * 60 * 24)); + return daysUntil <= 30 && daysUntil >= 0; + }); + + const landlords = await prisma.user.findMany({ where: { role: { in: ['LANDLORD', 'ADMIN'] } } }); + const cooldownStart = new Date(referenceDate); + cooldownStart.setDate(cooldownStart.getDate() - RESEND_COOLDOWN_DAYS); + + let remindersSent = 0; + for (const contract of urgent) { + const recentlySent = await prisma.notification.findFirst({ + where: { + type: NOTIFICATION_TYPE, + sentAt: { gte: cooldownStart }, + payload: { path: ['contractId'], equals: contract.id }, + }, + }); + if (recentlySent) continue; + + await Promise.all( + landlords.map((landlord) => + sendPushNotification({ + userId: landlord.id, + type: NOTIFICATION_TYPE, + title: 'Kündigungsfrist läuft bald ab', + body: `Für ${contract.room.roomNumber} (${contract.user.fullName}) endet die Kündigungsfrist in Kürze.`, + payload: { contractId: contract.id }, + }), + ), + ); + remindersSent++; + } + + return { remindersSent }; +} + +if (require.main === module) { + runNoticeDeadlineReminderJob() + .then((result) => { + console.log(`[noticeDeadlineReminderJob] ${result.remindersSent} Erinnerungen versendet.`); + return prisma.$disconnect(); + }) + .catch((err) => { + console.error('[noticeDeadlineReminderJob] Fehlgeschlagen:', err); + process.exit(1); + }); +}