Wire up OneSignal push delivery: register endpoint + reminder jobs

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.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 07:47:42 +00:00
parent 3629f5f66f
commit 762e17a864
4 changed files with 149 additions and 0 deletions

View File

@ -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",

View File

@ -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<CleaningArea, string> = {
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);
});
}

View File

@ -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<TrashType, string> = {
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);
});
}

View File

@ -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 });
});