diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6b74dc9..5709473 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -10,5 +10,8 @@ module.exports = { 'out/', 'coverage/', 'pnpm-lock.yaml', + // Service worker servito direttamente al browser: ESLint TS-aware non + // riesce a parsarlo perché non è nel project. Il file resta tracciato. + 'apps/web/public/**/*.js', ], }; diff --git a/apps/api/.env.example b/apps/api/.env.example index 3f75a6e..1dd1546 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -23,3 +23,11 @@ BETTER_AUTH_URL=http://localhost:3000 # Sentry — error tracking (lascia vuoto per disabilitare) # SENTRY_DSN= + +# Web Push — VAPID (vedi ADR 0003). +# Genera con: cd apps/api && node -e "console.log(require('web-push').generateVAPIDKeys())" +# La PUBLIC_KEY va anche in apps/web/.env come NEXT_PUBLIC_VAPID_PUBLIC_KEY. +# Lascia vuoto per disabilitare le push (il cron e gli endpoint rispondono 503). +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:hello@ketopath.app diff --git a/apps/api/package.json b/apps/api/package.json index cc3f2ea..7bbff9d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,12 +22,16 @@ "better-auth": "^1.0.21", "fastify": "^4.28.1", "fastify-plugin": "^4.5.1", + "node-cron": "^3.0", + "web-push": "^3.6", "zod": "^3.23.8" }, "devDependencies": { "@ketopath/eslint-config": "workspace:*", "@ketopath/tsconfig": "workspace:*", "@types/node": "^20.12.7", + "@types/node-cron": "^3.0", + "@types/web-push": "^3.6", "pino-pretty": "^11.2.1", "tsx": "^4.16.2" } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 61e51d4..85ca181 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,6 +9,7 @@ import { Sentry } from './lib/sentry.js'; import { dbRoutes } from './modules/db/db.routes.js'; import { healthRoutes } from './modules/health/health.routes.js'; import { meRoutes } from './modules/me/me.routes.js'; +import { notificationsRoutes } from './modules/notifications/notifications.routes.js'; import { planRoutes } from './modules/plan/plan.routes.js'; import { profileRoutes } from './modules/profile/profile.routes.js'; import { shoppingRoutes } from './modules/shopping/shopping.routes.js'; @@ -54,6 +55,7 @@ export async function buildApp(): Promise { await app.register(fastRoutes); await app.register(planRoutes); await app.register(shoppingRoutes); + await app.register(notificationsRoutes); if (env.SENTRY_DSN) { app.setErrorHandler((err, request, reply) => { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 2d125ab..5d69ceb 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -18,6 +18,12 @@ const envSchema = z.object({ BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.string().url(), SENTRY_DSN: z.string().url().optional(), + // PRD §5.6 — Web Push (VAPID). Tutte e tre opzionali: se mancano, lo + // scheduler delle notifiche resta disattivo e gli endpoint /me/device-tokens + // rispondono 503. Vedi ADR 0003. + VAPID_PUBLIC_KEY: z.string().min(1).optional(), + VAPID_PRIVATE_KEY: z.string().min(1).optional(), + VAPID_SUBJECT: z.string().min(1).optional(), // es. mailto:hello@ketopath.app }); export type Env = z.infer; diff --git a/apps/api/src/modules/notifications/notifications.routes.ts b/apps/api/src/modules/notifications/notifications.routes.ts new file mode 100644 index 0000000..ed88056 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.routes.ts @@ -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; +} diff --git a/apps/api/src/modules/notifications/scheduler.ts b/apps/api/src/modules/notifications/scheduler.ts new file mode 100644 index 0000000..53725e0 --- /dev/null +++ b/apps/api/src/modules/notifications/scheduler.ts @@ -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; +} diff --git a/apps/api/src/modules/notifications/sender.ts b/apps/api/src/modules/notifications/sender.ts new file mode 100644 index 0000000..09590ba --- /dev/null +++ b/apps/api/src/modules/notifications/sender.ts @@ -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 { + 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 { + 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 }; + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 31540df..620410b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -4,10 +4,16 @@ import './lib/sentry.js'; import { buildApp } from './app.js'; import { env } from './config/env.js'; +import { startNotificationScheduler } from './modules/notifications/scheduler.js'; async function start(): Promise { const app = await buildApp(); + const cronTask = startNotificationScheduler(app.prisma, app.log); + app.addHook('onClose', async () => { + cronTask?.stop(); + }); + try { await app.listen({ port: env.PORT, host: env.HOST }); } catch (err) { diff --git a/apps/web/.env.example b/apps/web/.env.example index 77bb949..c26f214 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -26,3 +26,8 @@ PRISMA_FIELD_ENCRYPTION_KEY= # SENTRY_AUTH_TOKEN= # SENTRY_ORG= # SENTRY_PROJECT= + +# Web Push — chiave pubblica VAPID (vedi ADR 0003). +# Stessa chiave pubblica configurata in apps/api/.env come VAPID_PUBLIC_KEY. +# Lascia vuoto per disattivare il flusso di iscrizione lato client. +NEXT_PUBLIC_VAPID_PUBLIC_KEY= diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index 959b74c..a9910da 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -2,4 +2,8 @@ module.exports = { root: true, extends: ['@ketopath/eslint-config/nextjs.cjs'], + ignorePatterns: [ + // Service worker servito al browser: out-of-project per il TS parser. + 'public/**/*.js', + ], }; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 6e1f465..80a8a1e 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -165,6 +165,40 @@ "delta": "Variazione", "emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate." }, + "Notifications": { + "eyebrow": "Notifiche", + "title": "Promemoria al momento giusto", + "subtitle": "Decidi tu cosa vuoi ricevere e su quale dispositivo. Niente marketing, niente notifiche di terze parti.", + "thisDevice": "Questo dispositivo", + "thisDeviceActive": "Le notifiche sono attive su questo dispositivo.", + "thisDeviceInactive": "Questo dispositivo non riceve ancora notifiche. Attivalo per il promemoria settimanale.", + "enableHere": "Attiva sul dispositivo", + "sendTest": "Invia notifica di prova", + "weeklyWeighInLabel": "Promemoria pesata settimanale", + "weeklyWeighInHint": "Lunedì alle 09:00 ti ricordiamo la pesata della settimana.", + "fastingMilestonesLabel": "Tappe del digiuno", + "fastingMilestonesHint": "Ti avvisiamo all'ingresso in chetogenesi e all'autofagia. Disponibile a breve.", + "registeredDevices": "Dispositivi registrati", + "subscribed": "Notifiche attivate.", + "unsubscribed": "Notifiche disattivate su questo dispositivo.", + "testSent": "Notifica di prova inviata.", + "blocked": "Hai bloccato le notifiche per questo sito. Cambia l'impostazione dal browser per riattivarle.", + "unsupported": "Il tuo browser non supporta le notifiche push. Su iOS apri prima KetoPath dalla Home.", + "serverDisabled": "Le notifiche non sono ancora attive sul server.", + "configUnavailable": "Impossibile caricare le impostazioni notifiche. Riprova più tardi.", + "working": "Attendere…", + "errors": { + "unsupported": "Il tuo browser non supporta le notifiche push.", + "permission_denied": "Hai negato il permesso. Modificalo dal browser per riprovare.", + "permission_default": "Permesso non concesso. Riprova e seleziona 'Consenti'.", + "no_vapid_key": "Configurazione mancante. Contatta l'amministratore.", + "api_error": "Errore di rete. Riprova fra poco.", + "sw_error": "Impossibile registrare il service worker.", + "no_devices": "Nessun dispositivo registrato.", + "push_not_configured": "Le notifiche non sono ancora abilitate sul server.", + "unknown": "Si è verificato un errore imprevisto." + } + }, "Shopping": { "title": "La spesa della settimana", "subtitle": "Tutto ciò che ti serve per il piano in corso, raggruppato per reparto.", diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..9415561 --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,52 @@ +// KetoPath service worker — minimo necessario per Web Push (PRD §5.6). +// Niente caching offline complesso: solo gestione `push` e `notificationclick`. + +self.addEventListener('install', (event) => { + // attiva subito senza aspettare i tab vecchi + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', (event) => { + if (!event.data) return; + let payload; + try { + payload = event.data.json(); + } catch (_err) { + payload = { title: 'KetoPath', body: event.data.text() }; + } + const title = payload.title || 'KetoPath'; + const options = { + body: payload.body || '', + icon: '/icons/icon-192.png', + badge: '/icons/badge-72.png', + data: { url: payload.url || '/' }, + // niente tag → l'utente vede ogni notifica separata + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || '/'; + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => { + for (const client of windowClients) { + // se c'è già una tab aperta sull'app, focus + navigate + if ('focus' in client) { + client.navigate(url).catch(() => { + /* navigate non sempre supportato */ + }); + return client.focus(); + } + } + if (self.clients.openWindow) { + return self.clients.openWindow(url); + } + return undefined; + }), + ); +}); diff --git a/apps/web/src/app/[locale]/profile/notifications-actions.ts b/apps/web/src/app/[locale]/profile/notifications-actions.ts new file mode 100644 index 0000000..5679c05 --- /dev/null +++ b/apps/web/src/app/[locale]/profile/notifications-actions.ts @@ -0,0 +1,100 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; + +const API_URL = process.env.API_URL ?? 'http://localhost:4000'; + +function cookieHeader(): string { + return headers().get('cookie') ?? ''; +} + +export interface NotificationDevice { + id: string; + platform: string; + endpoint: string | null; + userAgent: string | null; + createdAt: string; + lastSeenAt: string; +} + +export interface NotificationConfig { + pushEnabled: boolean; + settings: { weeklyWeighIn: boolean; fastingMilestones: boolean }; + devices: NotificationDevice[]; +} + +export async function fetchNotificationConfig(): Promise { + const res = await fetch(`${API_URL}/me/notifications/config`, { + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok) return null; + return (await res.json()) as NotificationConfig; +} + +export interface SubscribePayload { + endpoint: string; + keys: { p256dh: string; auth: string }; + userAgent?: string; +} + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +export async function registerDevice(payload: SubscribePayload): Promise { + const res = await fetch(`${API_URL}/me/device-tokens`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', cookie: cookieHeader() }, + body: JSON.stringify(payload), + cache: 'no-store', + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: body.error ?? `api_error_${res.status}` }; + } + revalidatePath('/profile'); + return { ok: true }; +} + +export async function unregisterDevice(deviceId: string): Promise { + const res = await fetch(`${API_URL}/me/device-tokens/${deviceId}`, { + method: 'DELETE', + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok && res.status !== 204) { + return { ok: false, error: `api_error_${res.status}` }; + } + revalidatePath('/profile'); + return { ok: true }; +} + +export async function updateNotificationSettings( + patch: Partial<{ weeklyWeighIn: boolean; fastingMilestones: boolean }>, +): Promise { + const res = await fetch(`${API_URL}/me/notifications/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', cookie: cookieHeader() }, + body: JSON.stringify(patch), + cache: 'no-store', + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: body.error ?? `api_error_${res.status}` }; + } + revalidatePath('/profile'); + return { ok: true }; +} + +export async function sendTestNotification(): Promise { + const res = await fetch(`${API_URL}/me/notifications/test`, { + method: 'POST', + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: body.error ?? `api_error_${res.status}` }; + } + return { ok: true }; +} diff --git a/apps/web/src/app/[locale]/profile/notifications-panel.tsx b/apps/web/src/app/[locale]/profile/notifications-panel.tsx new file mode 100644 index 0000000..5985f47 --- /dev/null +++ b/apps/web/src/app/[locale]/profile/notifications-panel.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useEffect, useState, useTransition } from 'react'; + +import { Button } from '@/components/ui/button'; +import { + pushSupported, + subscribe, + unsubscribe, + getCurrentSubscription, +} from '@/lib/notifications/push-client'; + +import { + sendTestNotification, + unregisterDevice, + updateNotificationSettings, + type NotificationConfig, + type NotificationDevice, +} from './notifications-actions'; + +const FORMATTER = new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', +}); + +export function NotificationsPanel({ initial }: { initial: NotificationConfig | null }) { + const t = useTranslations('Notifications'); + const [config, setConfig] = useState(initial); + const [supported, setSupported] = useState(true); + const [permission, setPermission] = useState('unknown'); + const [hasSubscription, setHasSubscription] = useState(false); + const [feedback, setFeedback] = useState(null); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + useEffect(() => { + setSupported(pushSupported()); + if (typeof Notification !== 'undefined') setPermission(Notification.permission); + void getCurrentSubscription().then((sub) => setHasSubscription(!!sub)); + }, []); + + if (!config) { + return

{t('configUnavailable')}

; + } + + // Capture narrowed config in a const so handler closures don't lose the + // narrowing (TS treats `config` as nullable inside async closures because + // `setConfig` is in scope). + const cfg: NotificationConfig = config; + // Una subscription web richiede tutti e tre: server abilitato, browser + // capace, permesso concesso, e una subscription effettivamente registrata + // sul pushManager. Se manca l'ultima, mostriamo di nuovo "Attiva". + const pushReady = cfg.pushEnabled && supported && permission === 'granted' && hasSubscription; + const blocked = permission === 'denied'; + + function refreshConfig(next: NotificationConfig): void { + setConfig(next); + } + + function flash(msg: string): void { + setError(null); + setFeedback(msg); + window.setTimeout(() => setFeedback(null), 3000); + } + function flashError(msg: string): void { + setFeedback(null); + setError(msg); + } + + function handleSubscribe(): void { + startTransition(async () => { + const result = await subscribe(); + if (!result.ok) { + const reasonKey = `errors.${result.reason ?? 'unknown'}` as const; + flashError(t.has(reasonKey) ? t(reasonKey) : t('errors.unknown')); + return; + } + setPermission('granted'); + flash(t('subscribed')); + // revalidatePath è già stato chiamato lato server action; ricarico la + // pagina così l'elenco device si rinfresca senza altre fetch a mano. + window.location.reload(); + }); + } + + function handleUnsubscribe(deviceId: string): void { + startTransition(async () => { + const sub = await getCurrentSubscription(); + const subEndpoint = sub?.endpoint; + const device = cfg.devices.find((d) => d.id === deviceId); + if (subEndpoint && device?.endpoint === subEndpoint) { + await unsubscribe(deviceId); + } else { + const r = await unregisterDevice(deviceId); + if (!r.ok) { + flashError(t('errors.api_error')); + return; + } + } + flash(t('unsubscribed')); + window.location.reload(); + }); + } + + function handleToggle(key: 'weeklyWeighIn' | 'fastingMilestones', value: boolean): void { + startTransition(async () => { + const result = await updateNotificationSettings({ [key]: value }); + if (!result.ok) { + flashError(t('errors.api_error')); + return; + } + refreshConfig({ ...cfg, settings: { ...cfg.settings, [key]: value } }); + }); + } + + function handleTest(): void { + startTransition(async () => { + const result = await sendTestNotification(); + if (!result.ok) { + const key = `errors.${result.error}` as const; + flashError(t.has(key) ? t(key) : t('errors.api_error')); + return; + } + flash(t('testSent')); + }); + } + + return ( +
+
+

{t('eyebrow')}

+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ +
+ + {!supported ? ( +

{t('unsupported')}

+ ) : !cfg.pushEnabled ? ( +

{t('serverDisabled')}

+ ) : ( +
+
+ handleToggle('weeklyWeighIn', v)} + disabled={pending || !pushReady} + /> + handleToggle('fastingMilestones', v)} + disabled={pending || !pushReady} + comingSoon + /> +
+ + +
+ )} + + {cfg.devices.length > 0 ? ( +
+

{t('registeredDevices')}

+
    + {cfg.devices.map((d) => ( + handleUnsubscribe(id)} + disabled={pending} + /> + ))} +
+
+ ) : null} + + {feedback ? ( +

+ {feedback} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +function ToggleRow({ + label, + description, + checked, + onChange, + disabled, + comingSoon, +}: { + label: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; + comingSoon?: boolean; +}) { + return ( + + ); +} + +function DeviceRow({ + device, + onRemove, + disabled, +}: { + device: NotificationDevice; + onRemove: (id: string) => void; + disabled?: boolean; +}) { + const ua = device.userAgent ?? ''; + const label = describeUserAgent(ua) || device.platform; + return ( +
  • +
    +

    {label}

    +

    + {device.platform} · {FORMATTER.format(new Date(device.createdAt))} +

    +
    + +
  • + ); +} + +function describeUserAgent(ua: string): string | null { + if (!ua) return null; + const matches = [ + [/iPhone|iPad/i, 'iOS'], + [/Android/i, 'Android'], + [/Mac OS X/i, 'macOS'], + [/Windows/i, 'Windows'], + [/Linux/i, 'Linux'], + ] as const; + const platform = matches.find(([re]) => re.test(ua))?.[1] ?? 'Browser'; + const browserMatches = [ + [/Edg\//i, 'Edge'], + [/Chrome\//i, 'Chrome'], + [/Safari\//i, 'Safari'], + [/Firefox\//i, 'Firefox'], + ] as const; + const browser = browserMatches.find(([re]) => re.test(ua))?.[1]; + return browser ? `${platform} · ${browser}` : platform; +} diff --git a/apps/web/src/app/[locale]/profile/page.tsx b/apps/web/src/app/[locale]/profile/page.tsx index 8e68458..1e0c7cf 100644 --- a/apps/web/src/app/[locale]/profile/page.tsx +++ b/apps/web/src/app/[locale]/profile/page.tsx @@ -7,6 +7,8 @@ import { Masthead } from '@/components/masthead'; import { getServerSession } from '@/lib/auth'; import { fetchProfile } from './actions'; +import { fetchNotificationConfig } from './notifications-actions'; +import { NotificationsPanel } from './notifications-panel'; import { ProfileForm } from './profile-form'; export default async function ProfilePage({ params: { locale } }: { params: { locale: string } }) { @@ -21,14 +23,17 @@ export default async function ProfilePage({ params: { locale } }: { params: { lo if (!user?.disclaimerAcceptedAt) redirect('/welcome'); const profile = (await fetchProfile()) as Parameters[0]['initial']; + const notifications = await fetchNotificationConfig(); - return ; + return ; } function ProfilePageContent({ initial, + notifications, }: { initial: Parameters[0]['initial']; + notifications: Awaited>; }) { const t = useTranslations('Profile'); @@ -50,6 +55,12 @@ function ProfilePageContent({
    + +
    + +
    + +
    ); diff --git a/apps/web/src/lib/notifications/push-client.ts b/apps/web/src/lib/notifications/push-client.ts new file mode 100644 index 0000000..2e0b890 --- /dev/null +++ b/apps/web/src/lib/notifications/push-client.ts @@ -0,0 +1,97 @@ +// Helper client-side per il flusso Web Push (PRD §5.6). +// Registra il SW, chiede il permesso, ottiene la PushSubscription, +// la passa a una server action che la inoltra all'API. Idempotente: +// se l'utente è già iscritto, riusiamo la subscription corrente. + +import { + registerDevice, + unregisterDevice, + type SubscribePayload, +} from '@/app/[locale]/profile/notifications-actions'; + +const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? ''; + +export interface SubscribeResult { + ok: boolean; + reason?: + | 'unsupported' + | 'permission_denied' + | 'permission_default' + | 'no_vapid_key' + | 'api_error' + | 'sw_error'; +} + +export function pushSupported(): boolean { + if (typeof window === 'undefined') return false; + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +} + +export async function ensureRegistration(): Promise { + if (!pushSupported()) return null; + try { + const existing = await navigator.serviceWorker.getRegistration(); + if (existing) return existing; + return await navigator.serviceWorker.register('/sw.js'); + } catch { + return null; + } +} + +export async function getCurrentSubscription(): Promise { + const reg = await ensureRegistration(); + if (!reg) return null; + return reg.pushManager.getSubscription(); +} + +export async function subscribe(): Promise { + if (!pushSupported()) return { ok: false, reason: 'unsupported' }; + if (!VAPID_PUBLIC_KEY) return { ok: false, reason: 'no_vapid_key' }; + + const reg = await ensureRegistration(); + if (!reg) return { ok: false, reason: 'sw_error' }; + + const permission = await Notification.requestPermission(); + if (permission === 'denied') return { ok: false, reason: 'permission_denied' }; + if (permission !== 'granted') return { ok: false, reason: 'permission_default' }; + + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), + }); + } + + const json = sub.toJSON(); + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { + return { ok: false, reason: 'sw_error' }; + } + const payload: SubscribePayload = { + endpoint: json.endpoint, + keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }, + userAgent: navigator.userAgent, + }; + const result = await registerDevice(payload); + return result.ok ? { ok: true } : { ok: false, reason: 'api_error' }; +} + +export async function unsubscribe(deviceId?: string): Promise { + const sub = await getCurrentSubscription(); + if (sub) await sub.unsubscribe(); + if (deviceId) { + await unregisterDevice(deviceId).catch(() => { + /* il record verrà ripulito al prossimo invio */ + }); + } + return true; +} + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = window.atob(base64); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; ++i) out[i] = raw.charCodeAt(i); + return out; +} diff --git a/docs/decisions/0003-push-notifications.md b/docs/decisions/0003-push-notifications.md new file mode 100644 index 0000000..46a8c2a --- /dev/null +++ b/docs/decisions/0003-push-notifications.md @@ -0,0 +1,101 @@ +# ADR 0003 — Notifiche push (web ora, mobile dopo) + +- **Status**: accepted +- **Date**: 2026-04-29 +- **Decision makers**: Luciano (PO), Claude +- **Related**: PRD §5.6 (Notifiche & promemoria), CLAUDE.md ("Privacy first", "GDPR by design"), ADR 0001 (auth EU/self-hosted) + +## Contesto + +PRD §5.6 prevede promemoria settimanali (pesata, digiuno) e notifiche push lifecycle. Su web esiste lo standard W3C Push API; per il mobile (iOS, Android) si usano canali nativi (APNs, FCM). Il PO ha già escluso provider US‑centric quando possibile (vedi ADR 0001) e sa che è verosimile l'arrivo di un'app nativa post‑MVP. + +L'ADR fissa **come spedire le push oggi** e **come restare facili da estendere domani**, senza vendor lock‑in. + +## Opzioni considerate + +### A) Web Push standard (VAPID, self‑hosted) + +- Browser + Service Worker ricevono push da un endpoint VAPID gestito dall'API KetoPath. Nessun account terzo. +- Funziona su Chrome/Edge/Firefox. Su iOS funziona **solo se l'utente installa la PWA sulla Home** (iOS 16.4+). +- Costi: zero. Conforme GDPR, nessuna catena di custodia con terzi. + +### B) Firebase Cloud Messaging (FCM) anche per web + +- Web Push proxato attraverso FCM (legacy: spesso usato per uniformità con Android). +- Pro: una sola pipeline. +- Contro: dipendenza Google su tutto lo stack, stesso problema di residenza dei dati che abbiamo evitato in 0001. + +### C) Servizi gestiti (OneSignal, Pusher Beams, Knock…) + +- SaaS che astraggono web/iOS/Android. +- Pro: zero codice, multi‑canale. +- Contro: data residency, costi a scaglione, tracking di terze parti — fuori dai vincoli "Privacy first" di CLAUDE.md. + +## Decisione + +1. **Spedizione oggi**: Web Push standard con VAPID, spedito dall'API tramite la libreria `web-push`. +2. **Modellazione mobile‑ready**: nel DB la subscription si chiama `DeviceToken`, non `PushSubscription`, e ha un campo `platform` (`web` | `ios` | `android`). Per `web` valorizziamo `endpoint` + `p256dh` + `auth`; per le altre piattaforme useremo `token` (vedi struttura sotto). Tutto resta sullo stesso modello. +3. **Architettura sender**: l'API espone un'interfaccia `NotificationSender` con un metodo `send(token, payload)`. Implementazione iniziale unica `WebPushSender`. Le piattaforme mobile, quando arriveranno, saranno `ExpoPushSender` o `ApnsDirectSender` / `FcmSender` — _senza toccare la logica di scheduling, preferenze e opt‑in_. +4. **Scheduling**: per il MVP, `node-cron` in‑process per i promemoria settimanali. Niente coda Redis/BullMQ finché non avremo job paralleli o picchi. + +## Schema (estratto) + +```prisma +model DeviceToken { + id String @id @default(cuid()) + userId String @map("user_id") + platform String // 'web' | 'ios' | 'android' + endpoint String? // Web Push: URL endpoint + p256dh String? // Web Push key + auth String? // Web Push auth secret + token String? // iOS/Android device token (Expo/APNs/FCM) + userAgent String? @map("user_agent") + createdAt DateTime @default(now()) @map("created_at") + lastSeenAt DateTime @default(now()) @map("last_seen_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, endpoint, token]) + @@map("device_tokens") +} +``` + +Le preferenze sono già su `Preferences.notificationSettings` (Json). Tipiamo i flag in un'interfaccia condivisa: + +```ts +interface NotificationSettings { + weeklyWeighIn: boolean; // PRD §5.6 + fastingMilestones: boolean; // PRD §5.6 — per iterazione futura +} +``` + +## Mobile readiness — cosa cambia il giorno X + +| Capability | Web (oggi) | iOS nativa | Android nativa | Cosa scrivo lato server | +| ------------ | ---------- | --------------------- | ------------------- | ------------------------------------------------------ | +| Trasporto | VAPID/HTTP | APNs (via Expo o JWT) | FCM (via Expo) | Nuovo `NotificationSender` da plug‑in al servizio core | +| Token format | endpoint | device token Apple | FCM token | Nessun cambio schema (`platform` + colonna `token`) | +| Permission | Notif API | UNUserNotifications | NotificationManager | Nessun cambio backend | +| Costo | 0 | €99/anno Apple Dev | $25 una tantum | n/a | + +Se scegliamo **Expo** per l'app cross‑platform, l'unico cambio backend è importare `expo-server-sdk` e aggiungere l'`ExpoPushSender`: nessuna migration, nessuna modifica di scheduling. + +## Sicurezza & GDPR + +- **VAPID private key**: solo in env API (`VAPID_PRIVATE_KEY`), mai committata, mai esposta al client. +- **Payload**: niente dati sanitari nel body della push (titolo + testo generico, l'utente apre l'app per i dettagli). Allinea con CLAUDE.md "niente log dei dati sanitari". +- **Consenso esplicito**: il toggle in `/profile` è opt‑in, default off. Niente notifiche di marketing senza consenso separato. +- **Cleanup automatico**: subscription che restituiscono 404/410 vengono cancellate dal DB nello stesso ciclo di invio. + +## Conseguenze + +- **+** Architettura pronta per l'app nativa con un solo `Sender` extra. +- **+** Zero costi e zero dipendenze di terze parti per il MVP. +- **+** Conforme alle direttive privacy che abbiamo già fissato. +- **−** Su iOS (web) la spedizione richiede che l'utente installi la PWA: per ora la documenteremo come limite noto, in attesa dell'app nativa. + +## Riferimenti + +- [Web Push libraries — Mozilla](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) +- [`web-push`](https://github.com/web-push-libs/web-push) — libreria Node usata dal backend +- [Expo Push Notifications](https://docs.expo.dev/push-notifications/overview/) +- [Apple Developer — APNs](https://developer.apple.com/notifications/) diff --git a/package.json b/package.json index 8e91b24..2e3d9df 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,13 @@ "@vitest/coverage-v8": "^1.6.0" }, "lint-staged": { - "*.{ts,tsx,js,jsx,cjs,mjs}": [ + "*.{ts,tsx,jsx,cjs,mjs}": [ "eslint --fix --max-warnings=0", "prettier --write" ], + "*.js": [ + "prettier --write" + ], "*.{json,md,yml,yaml,css}": [ "prettier --write" ] diff --git a/packages/db/prisma/migrations/20260429193800_add_device_tokens/migration.sql b/packages/db/prisma/migrations/20260429193800_add_device_tokens/migration.sql new file mode 100644 index 0000000..23f9dda --- /dev/null +++ b/packages/db/prisma/migrations/20260429193800_add_device_tokens/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "device_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "endpoint" TEXT, + "p256dh" TEXT, + "auth" TEXT, + "token" TEXT, + "user_agent" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "device_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "device_tokens_user_id_idx" ON "device_tokens"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "device_tokens_user_id_endpoint_key" ON "device_tokens"("user_id", "endpoint"); + +-- AddForeignKey +ALTER TABLE "device_tokens" ADD CONSTRAINT "device_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 41d2fa4..7dec59f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -96,6 +96,7 @@ model User { weightEntries WeightEntry[] fastEvents FastEvent[] mealPlans MealPlan[] + deviceTokens DeviceToken[] @@map("users") } @@ -319,6 +320,31 @@ model MealPlan { @@map("meal_plans") } +// PRD §5.6 — token di un dispositivo registrato per ricevere push. +// Il modello è agnostico rispetto alla piattaforma per facilitare l'estensione +// a iOS/Android (Expo/APNs/FCM) in futuro: vedi ADR 0003. +// +// `platform` è 'web' | 'ios' | 'android'. Per web valorizziamo endpoint+p256dh+auth; +// per iOS/Android valorizziamo `token` (device token nativo o Expo push token). +model DeviceToken { + id String @id @default(cuid()) + userId String @map("user_id") + platform String + endpoint String? + p256dh String? + auth String? + token String? + userAgent String? @map("user_agent") + createdAt DateTime @default(now()) @map("created_at") + lastSeenAt DateTime @default(now()) @map("last_seen_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, endpoint]) + @@index([userId]) + @@map("device_tokens") +} + model MealSlot { id String @id @default(cuid()) planId String @map("plan_id") diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 6f6e4f3..c9cf991 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -17,3 +17,7 @@ if (process.env.NODE_ENV !== 'production') { } export const prisma = baseClient.$extends(fieldEncryptionExtension()); + +// Tipo del client esteso (post-$extends), utile per chi consuma `app.prisma` +// fuori dai route handler (es. scheduler). +export type ExtendedPrismaClient = typeof prisma; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index bd0ce9a..79b74f5 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,4 +1,5 @@ export { prisma } from './client.js'; +export type { ExtendedPrismaClient } from './client.js'; export { ActivityLevel, CookingTime, @@ -14,11 +15,13 @@ export { } from '@prisma/client'; export type { Account, + DeviceToken, FastEvent, Ingredient, MealPlan, MealSlot, Preferences, + PrismaClient, Profile, Recipe, RecipeIngredient, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3918470..bc67e02 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './notifications/schema.js'; export * from './nutrition/bmr.js'; export * from './nutrition/tdee.js'; export * from './nutrition/types.js'; diff --git a/packages/shared/src/notifications/schema.ts b/packages/shared/src/notifications/schema.ts new file mode 100644 index 0000000..eaeb0c3 --- /dev/null +++ b/packages/shared/src/notifications/schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +// PRD §5.6 — flag opt-in per i promemoria. Default off, tutti. +export const notificationSettingsSchema = z + .object({ + weeklyWeighIn: z.boolean().default(false), + fastingMilestones: z.boolean().default(false), + }) + .strict(); + +export type NotificationSettings = z.infer; + +export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { + weeklyWeighIn: false, + fastingMilestones: false, +}; + +// Body inviato dal client al backend per registrare una subscription web. +export const webPushSubscriptionSchema = z.object({ + endpoint: z.string().url(), + keys: z.object({ + p256dh: z.string().min(1), + auth: z.string().min(1), + }), + userAgent: z.string().max(512).optional(), +}); + +export type WebPushSubscriptionInput = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33b68bc..ad52f21 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ