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>
This commit is contained in:
1 parent
cdfc685855
commit
bb48357179
26 files changed
+1144
-2
No files matched your search
@@ -0,0 +1,89 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in new issue
Block a user