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,136 @@
|
||||
// PRD §5.6 — registrazione device per push, gestione preferenze.
|
||||
|
||||
import {
|
||||
DEFAULT_NOTIFICATION_SETTINGS,
|
||||
notificationSettingsSchema,
|
||||
webPushSubscriptionSchema,
|
||||
type NotificationSettings,
|
||||
} from '@ketopath/shared';
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
|
||||
import { pushIsEnabled, sendToDevice } from './sender.js';
|
||||
|
||||
export const notificationsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/me/notifications/config', { preHandler: requireAuth() }, async (request) => {
|
||||
const userId = request.user!.id;
|
||||
const prefs = await fastify.prisma.preferences.findUnique({ where: { userId } });
|
||||
const tokens = await fastify.prisma.deviceToken.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
platform: true,
|
||||
endpoint: true,
|
||||
userAgent: true,
|
||||
createdAt: true,
|
||||
lastSeenAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const settings = readSettings(prefs?.notificationSettings ?? null);
|
||||
return {
|
||||
pushEnabled: pushIsEnabled(),
|
||||
settings,
|
||||
devices: tokens,
|
||||
};
|
||||
});
|
||||
|
||||
fastify.post('/me/device-tokens', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
if (!pushIsEnabled()) {
|
||||
return reply.code(503).send({ error: 'push_not_configured' });
|
||||
}
|
||||
const parsed = webPushSubscriptionSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const userId = request.user!.id;
|
||||
const { endpoint, keys, userAgent } = parsed.data;
|
||||
|
||||
const data = {
|
||||
userId,
|
||||
platform: 'web',
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
userAgent: userAgent ?? null,
|
||||
lastSeenAt: new Date(),
|
||||
};
|
||||
|
||||
const token = await fastify.prisma.deviceToken.upsert({
|
||||
where: { userId_endpoint: { userId, endpoint } },
|
||||
create: data,
|
||||
update: { ...data, createdAt: undefined as never },
|
||||
});
|
||||
|
||||
return reply.code(201).send({ device: { id: token.id } });
|
||||
});
|
||||
|
||||
fastify.delete('/me/device-tokens/:id', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const id = (request.params as { id: string }).id;
|
||||
const owned = await fastify.prisma.deviceToken.findFirst({ where: { id, userId } });
|
||||
if (!owned) return reply.code(404).send({ error: 'device_not_found' });
|
||||
await fastify.prisma.deviceToken.delete({ where: { id } });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
fastify.patch(
|
||||
'/me/notifications/settings',
|
||||
{ preHandler: requireAuth() },
|
||||
async (request, reply) => {
|
||||
const parsed = notificationSettingsSchema.partial().safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_body', issues: parsed.error.issues });
|
||||
}
|
||||
const userId = request.user!.id;
|
||||
const existing = await fastify.prisma.preferences.findUnique({ where: { userId } });
|
||||
const current = readSettings(existing?.notificationSettings ?? null);
|
||||
// partial().safeParse can return undefined per key; filtra prima di
|
||||
// sovrascrivere così non spegniamo flag attivi con un payload parziale.
|
||||
const patch = Object.fromEntries(
|
||||
Object.entries(parsed.data).filter(([, v]) => v !== undefined),
|
||||
);
|
||||
const next: NotificationSettings = { ...current, ...patch };
|
||||
|
||||
await fastify.prisma.preferences.upsert({
|
||||
where: { userId },
|
||||
create: { userId, notificationSettings: next },
|
||||
update: { notificationSettings: next },
|
||||
});
|
||||
|
||||
return { settings: next };
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post('/me/notifications/test', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
if (!pushIsEnabled()) {
|
||||
return reply.code(503).send({ error: 'push_not_configured' });
|
||||
}
|
||||
const userId = request.user!.id;
|
||||
const tokens = await fastify.prisma.deviceToken.findMany({ where: { userId } });
|
||||
if (tokens.length === 0) return reply.code(409).send({ error: 'no_devices' });
|
||||
|
||||
let sent = 0;
|
||||
let removed = 0;
|
||||
for (const t of tokens) {
|
||||
const result = await sendToDevice(t, {
|
||||
title: 'KetoPath',
|
||||
body: 'Notifica di prova: la connessione funziona.',
|
||||
url: '/profile',
|
||||
});
|
||||
if (result.ok) sent++;
|
||||
if (result.expired) {
|
||||
await fastify.prisma.deviceToken.delete({ where: { id: t.id } });
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return { sent, removed };
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// PRD §5.6 — invio push agnostico rispetto alla piattaforma.
|
||||
// Per ora c'è solo l'implementazione web (VAPID). Aggiungere mobile = un nuovo
|
||||
// `Sender` (Expo / APNs / FCM) senza toccare i call site. Vedi ADR 0003.
|
||||
|
||||
import type { DeviceToken } from '@ketopath/db';
|
||||
// `web-push` espone CommonJS: il default import è ciò che funziona a runtime,
|
||||
// ma il plugin `import` di ESLint non lo riconosce — disabilitiamo solo qui.
|
||||
// eslint-disable-next-line import/default
|
||||
import webpush from 'web-push';
|
||||
import type { PushSubscription } from 'web-push';
|
||||
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
url?: string;
|
||||
// Niente dati sanitari nel payload (CLAUDE.md "Privacy first").
|
||||
}
|
||||
|
||||
export interface SendResult {
|
||||
ok: boolean;
|
||||
/** true ↔ il token è scaduto/revocato e va cancellato dal DB. */
|
||||
expired: boolean;
|
||||
errorCode?: number;
|
||||
}
|
||||
|
||||
let webPushConfigured = false;
|
||||
function configureWebPush(): boolean {
|
||||
if (webPushConfigured) return true;
|
||||
if (!env.VAPID_PUBLIC_KEY || !env.VAPID_PRIVATE_KEY || !env.VAPID_SUBJECT) {
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line import/no-named-as-default-member
|
||||
webpush.setVapidDetails(env.VAPID_SUBJECT, env.VAPID_PUBLIC_KEY, env.VAPID_PRIVATE_KEY);
|
||||
webPushConfigured = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pushIsEnabled(): boolean {
|
||||
return configureWebPush();
|
||||
}
|
||||
|
||||
export async function sendToDevice(
|
||||
device: DeviceToken,
|
||||
payload: NotificationPayload,
|
||||
): Promise<SendResult> {
|
||||
if (device.platform === 'web') {
|
||||
return sendWebPush(device, payload);
|
||||
}
|
||||
// Placeholder per iOS/Android — vedi ADR 0003.
|
||||
return { ok: false, expired: false, errorCode: 501 };
|
||||
}
|
||||
|
||||
async function sendWebPush(device: DeviceToken, payload: NotificationPayload): Promise<SendResult> {
|
||||
if (!configureWebPush()) {
|
||||
return { ok: false, expired: false };
|
||||
}
|
||||
if (!device.endpoint || !device.p256dh || !device.auth) {
|
||||
return { ok: false, expired: true }; // record incompleto: trattalo come scaduto
|
||||
}
|
||||
const subscription: PushSubscription = {
|
||||
endpoint: device.endpoint,
|
||||
keys: { p256dh: device.p256dh, auth: device.auth },
|
||||
};
|
||||
try {
|
||||
// eslint-disable-next-line import/no-named-as-default-member
|
||||
await webpush.sendNotification(subscription, JSON.stringify(payload), {
|
||||
TTL: 60 * 60, // 1 ora
|
||||
});
|
||||
return { ok: true, expired: false };
|
||||
} catch (err) {
|
||||
const e = err as { statusCode?: number };
|
||||
const status = e.statusCode ?? 0;
|
||||
return { ok: false, expired: status === 404 || status === 410, errorCode: status };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user