ADR 0003: VAPID self-hosted today, DeviceToken model agnostic to
platform so iOS/Android (Expo/APNs/FCM) plug in as new senders later.
Backend (apps/api/src/modules/notifications)
- sender.ts: NotificationSender interface, WebPushSender via VAPID
- notifications.routes.ts: GET /me/notifications/config, POST/DELETE
/me/device-tokens, PATCH /me/notifications/settings, POST /me/notifications/test
- scheduler.ts: node-cron Mon 09:00 Europe/Rome for weekly weigh-in
reminder; auto-cleanup of expired tokens on 404/410
- env: VAPID_PUBLIC_KEY/PRIVATE_KEY/SUBJECT (all optional → push gracefully off)
Frontend
- public/sw.js minimal (push + notificationclick)
- lib/notifications/push-client.ts: subscribe / unsubscribe / getCurrentSubscription
- profile/notifications-{actions,panel}.tsx: editorial panel with toggles,
device list, "send test", per-device removal
- pushReady requires both permission AND active subscription (covers the
case where the user revoked the SW but kept the browser permission)
Schema
- DeviceToken { userId, platform, endpoint, p256dh, auth, token, userAgent,
createdAt, lastSeenAt } with unique(userId, endpoint)
- ExtendedPrismaClient type exported from @ketopath/db
- NotificationSettings zod schema in @ketopath/shared
Tooling
- lint-staged: split .js out of eslint glob so service worker is only
formatted (it lives outside the TS project)
i18n
- Notifications namespace (it) with typed error keys
Smoke tested: POST /me/device-tokens 201, POST /me/notifications/test 200,
real push delivered to a macOS Chrome device.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
90 lines
2.8 KiB
TypeScript
90 lines
2.8 KiB
TypeScript
// PRD §5.6 — promemoria settimanale pesata.
|
|
// Cron lunedì 09:00 Europe/Rome. Mandiamo a tutti gli utenti che hanno
|
|
// il flag `weeklyWeighIn` attivo, e ripuliamo i token scaduti.
|
|
|
|
import type { ExtendedPrismaClient } from '@ketopath/db';
|
|
import {
|
|
DEFAULT_NOTIFICATION_SETTINGS,
|
|
notificationSettingsSchema,
|
|
type NotificationSettings,
|
|
} from '@ketopath/shared';
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
// `node-cron` espone CommonJS: il default import è ciò che funziona a runtime.
|
|
// eslint-disable-next-line import/default
|
|
import cron from 'node-cron';
|
|
import type { ScheduledTask } from 'node-cron';
|
|
|
|
import { pushIsEnabled, sendToDevice } from './sender.js';
|
|
|
|
const WEEKLY_CRON = '0 9 * * 1'; // lunedì alle 09:00
|
|
|
|
export function startNotificationScheduler(
|
|
prisma: ExtendedPrismaClient,
|
|
log: FastifyBaseLogger,
|
|
): ScheduledTask | null {
|
|
if (!pushIsEnabled()) {
|
|
log.info('[notifications] VAPID keys missing — scheduler disabled');
|
|
return null;
|
|
}
|
|
// eslint-disable-next-line import/no-named-as-default-member
|
|
const task = cron.schedule(
|
|
WEEKLY_CRON,
|
|
() => {
|
|
void runWeeklyWeighInJob(prisma, log);
|
|
},
|
|
{ timezone: 'Europe/Rome' },
|
|
);
|
|
log.info('[notifications] weekly weigh-in cron scheduled (Mon 09:00 Europe/Rome)');
|
|
return task;
|
|
}
|
|
|
|
export async function runWeeklyWeighInJob(
|
|
prisma: ExtendedPrismaClient,
|
|
log: FastifyBaseLogger,
|
|
): Promise<{ candidates: number; sent: number; expired: number }> {
|
|
const candidates = await prisma.user.findMany({
|
|
where: {
|
|
preferences: { isNot: null },
|
|
deviceTokens: { some: {} },
|
|
},
|
|
select: {
|
|
id: true,
|
|
preferences: { select: { notificationSettings: true } },
|
|
deviceTokens: true,
|
|
},
|
|
});
|
|
|
|
let sent = 0;
|
|
let expired = 0;
|
|
for (const u of candidates) {
|
|
const settings = readSettings(u.preferences?.notificationSettings ?? null);
|
|
if (!settings.weeklyWeighIn) continue;
|
|
for (const token of u.deviceTokens) {
|
|
const r = await sendToDevice(token, {
|
|
title: 'È giorno di pesata',
|
|
body: 'Apri KetoPath per registrare il peso della settimana.',
|
|
url: '/tracking',
|
|
});
|
|
if (r.ok) sent++;
|
|
if (r.expired) {
|
|
expired++;
|
|
await prisma.deviceToken.delete({ where: { id: token.id } }).catch(() => {
|
|
/* race con altra cancellazione: ignora */
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
log.info(
|
|
{ candidates: candidates.length, sent, expired },
|
|
'[notifications] weekly weigh-in job completed',
|
|
);
|
|
return { candidates: candidates.length, sent, expired };
|
|
}
|
|
|
|
function readSettings(value: unknown): NotificationSettings {
|
|
if (!value || typeof value !== 'object') return DEFAULT_NOTIFICATION_SETTINGS;
|
|
const parsed = notificationSettingsSchema.safeParse(value);
|
|
return parsed.success ? parsed.data : DEFAULT_NOTIFICATION_SETTINGS;
|
|
}
|