Files
ketopath/apps/api/src/modules/notifications/scheduler.ts
T
lucianoandClaude Opus 4.7 bb48357179 feat: web push notifications, mobile-ready (PRD §5.6)
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>
2026-04-29 21:58:35 +02:00

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