From 762e17a864e2b7898b7d8579c1c65144f301efc3 Mon Sep 17 00:00:00 2001 From: bernd Date: Thu, 13 Aug 2026 07:47:42 +0000 Subject: [PATCH] Wire up OneSignal push delivery: register endpoint + reminder jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendPushNotification() (services/notificationService.ts) already called the real OneSignal API correctly, but two things made it unreachable: no endpoint ever set User.pushToken, and no job existed for the two push-based requirements beyond rent dunning — cleaning check-in reminders and the trash-calendar evening push. Both CleaningTask and TrashSchedule already had an unused `reminderSentAt` column, so the schema anticipated this and was just never wired up. - PUT /v1/auth/push-token: registers the current device's OneSignal Player ID. - jobs/cleaningReminderJob.ts, jobs/trashReminderJob.ts: same find-due/send-once/mark-reminderSentAt pattern as the existing dailyDunningJob.ts. Safe to run without OneSignal credentials configured — sendPushNotification degrades to an in-app Notification log when ONESIGNAL_APP_ID/API_KEY or a user's pushToken are absent. Not done here (needs real third-party setup from the user, same as the Cloudflare tunnel token pattern): ONESIGNAL_APP_ID/API_KEY in .env, and a frontend OneSignal Web SDK integration (service worker, VAPID keys) to actually populate pushToken from a real browser. --- backend/package.json | 2 + backend/src/jobs/cleaningReminderJob.ts | 62 ++++++++++++++++++++++ backend/src/jobs/trashReminderJob.ts | 70 +++++++++++++++++++++++++ backend/src/routes/auth.ts | 15 ++++++ 4 files changed, 149 insertions(+) create mode 100644 backend/src/jobs/cleaningReminderJob.ts create mode 100644 backend/src/jobs/trashReminderJob.ts diff --git a/backend/package.json b/backend/package.json index 0e13721..535e176 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,8 @@ "build": "tsc -p tsconfig.json", "start": "node dist/app.js", "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", "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/cleaningReminderJob.ts b/backend/src/jobs/cleaningReminderJob.ts new file mode 100644 index 0000000..68f56bd --- /dev/null +++ b/backend/src/jobs/cleaningReminderJob.ts @@ -0,0 +1,62 @@ +import 'dotenv/config'; +import { PrismaClient, CleaningArea } from '@prisma/client'; +import { sendPushNotification } from '../services/notificationService'; + +const prisma = new PrismaClient(); + +const AREA_LABEL: Record = { + BATHROOM_1: 'Bad 1', + BATHROOM_2: 'Bad 2', + KITCHEN: 'Wohnküche', + HALLWAY_LAUNDRY: 'Waschraum/Flur', +}; + +/** + * Täglicher Cron-Job (analog dailyDunningJob.ts): erinnert Mieter per Push an + * eine offene Putzaufgabe, die morgen fällig ist. Verschickt (max. 1x je + * Aufgabe) und markiert per `reminderSentAt`. + */ +export async function runCleaningReminderJob(referenceDate: Date = new Date()) { + const tomorrowStart = new Date(referenceDate); + tomorrowStart.setDate(tomorrowStart.getDate() + 1); + tomorrowStart.setHours(0, 0, 0, 0); + const tomorrowEnd = new Date(tomorrowStart); + tomorrowEnd.setHours(23, 59, 59, 999); + + const dueTasks = await prisma.cleaningTask.findMany({ + where: { + status: 'PENDING', + dueDate: { gte: tomorrowStart, lte: tomorrowEnd }, + reminderSentAt: null, + }, + include: { assignedUser: true }, + }); + + for (const task of dueTasks) { + await prisma.cleaningTask.update({ + where: { id: task.id }, + data: { reminderSentAt: new Date() }, + }); + + await sendPushNotification({ + userId: task.assignedUserId, + type: 'CLEANING_REMINDER', + title: 'Putzplan-Erinnerung', + body: `Morgen ist "${AREA_LABEL[task.area]}" an der Reihe. Bitte nicht vergessen!`, + }); + } + + return { remindersSent: dueTasks.length }; +} + +if (require.main === module) { + runCleaningReminderJob() + .then((result) => { + console.log(`[cleaningReminderJob] ${result.remindersSent} Erinnerungen versendet.`); + return prisma.$disconnect(); + }) + .catch((err) => { + console.error('[cleaningReminderJob] Fehlgeschlagen:', err); + process.exit(1); + }); +} diff --git a/backend/src/jobs/trashReminderJob.ts b/backend/src/jobs/trashReminderJob.ts new file mode 100644 index 0000000..24dd13d --- /dev/null +++ b/backend/src/jobs/trashReminderJob.ts @@ -0,0 +1,70 @@ +import 'dotenv/config'; +import { PrismaClient, TrashType } from '@prisma/client'; +import { sendPushNotification } from '../services/notificationService'; + +const prisma = new PrismaClient(); + +const TRASH_LABEL: Record = { + RESTMUELL: 'Restmüll', + BIOMUELL: 'Biomüll', + GELBER_SACK: 'Gelber Sack', + PAPIER: 'Papiertonne', + GLAS: 'Glas', +}; + +/** + * Täglicher Cron-Job (analog dailyDunningJob.ts): "Vorabend-Push" für den + * Müll-Kalender — erinnert alle Mieter am Vorabend an eine Abholung am + * nächsten Tag. Verschickt (max. 1x je Termin) und markiert per + * `reminderSentAt`. + */ +export async function runTrashReminderJob(referenceDate: Date = new Date()) { + const tomorrowStart = new Date(referenceDate); + tomorrowStart.setDate(tomorrowStart.getDate() + 1); + tomorrowStart.setHours(0, 0, 0, 0); + const tomorrowEnd = new Date(tomorrowStart); + tomorrowEnd.setHours(23, 59, 59, 999); + + const dueEntries = await prisma.trashSchedule.findMany({ + where: { + date: { gte: tomorrowStart, lte: tomorrowEnd }, + reminderSentAt: null, + }, + }); + + if (!dueEntries.length) return { remindersSent: 0 }; + + const tenants = await prisma.user.findMany({ where: { role: 'TENANT' } }); + + for (const entry of dueEntries) { + await prisma.trashSchedule.update({ + where: { id: entry.id }, + data: { reminderSentAt: new Date() }, + }); + + await Promise.all( + tenants.map((tenant) => + sendPushNotification({ + userId: tenant.id, + type: 'TRASH_REMINDER', + title: 'Müll-Erinnerung', + body: `Morgen wird "${TRASH_LABEL[entry.type]}" abgeholt. Bitte heute Abend rausstellen.`, + }), + ), + ); + } + + return { remindersSent: dueEntries.length }; +} + +if (require.main === module) { + runTrashReminderJob() + .then((result) => { + console.log(`[trashReminderJob] ${result.remindersSent} Erinnerungen versendet.`); + return prisma.$disconnect(); + }) + .catch((err) => { + console.error('[trashReminderJob] Fehlgeschlagen:', err); + process.exit(1); + }); +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index a8a9c51..c42d06b 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -38,3 +38,18 @@ authRouter.get('/auth/me', requireAuth, async (req: AuthedRequest, res: Response if (!user) return res.status(404).json({ error: 'Nutzer nicht gefunden' }); res.status(200).json({ user: publicUser(user) }); }); + +// PUT /v1/auth/push-token { pushToken } -> registriert die OneSignal Player ID +// des aktuellen Geräts. Ohne registrierten Token bleibt sendPushNotification() +// ein reines In-App-Log (siehe services/notificationService.ts). +authRouter.put('/auth/push-token', requireAuth, async (req: AuthedRequest, res: Response) => { + const { pushToken } = req.body || {}; + if (pushToken !== null && typeof pushToken !== 'string') { + return res.status(400).json({ error: 'pushToken muss ein String oder null sein' }); + } + await prisma.user.update({ + where: { id: req.user!.id }, + data: { pushToken: pushToken || null }, + }); + res.status(200).json({ ok: true }); +});