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:
lucianoandClaude Opus 4.7 committed 2026-04-29 21:58:35 +02:00
1 parent cdfc685855
commit bb48357179
26 files changed
+1144 -2

No files matched your search

+8
View File
@@ -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
+4
View File
@@ -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"
}
+2
View File
@@ -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<FastifyInstance> {
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) => {
+6
View File
@@ -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<typeof envSchema>;
@@ -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 };
}
}
+6
View File
@@ -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<void> {
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) {
+5
View File
@@ -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=
+4
View File
@@ -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',
],
};
+34
View File
@@ -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.",
+52
View File
@@ -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;
}),
);
});
@@ -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<NotificationConfig | null> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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 };
}
@@ -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<NotificationConfig | null>(initial);
const [supported, setSupported] = useState<boolean>(true);
const [permission, setPermission] = useState<NotificationPermission | 'unknown'>('unknown');
const [hasSubscription, setHasSubscription] = useState<boolean>(false);
const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
useEffect(() => {
setSupported(pushSupported());
if (typeof Notification !== 'undefined') setPermission(Notification.permission);
void getCurrentSubscription().then((sub) => setHasSubscription(!!sub));
}, []);
if (!config) {
return <p className="font-display text-ink-soft text-base italic">{t('configUnavailable')}</p>;
}
// 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 (
<section className="space-y-10">
<header className="space-y-3">
<p className="editorial-eyebrow">{t('eyebrow')}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight tracking-tight">
{t('title')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('subtitle')}
</p>
</header>
<div className="border-ink/15 border-t" />
{!supported ? (
<p className="font-display text-ink-soft text-base italic">{t('unsupported')}</p>
) : !cfg.pushEnabled ? (
<p className="font-display text-ink-soft text-base italic">{t('serverDisabled')}</p>
) : (
<div className="grid gap-10 md:grid-cols-12">
<div className="space-y-5 md:col-span-7">
<ToggleRow
label={t('weeklyWeighInLabel')}
description={t('weeklyWeighInHint')}
checked={cfg.settings.weeklyWeighIn}
onChange={(v) => handleToggle('weeklyWeighIn', v)}
disabled={pending || !pushReady}
/>
<ToggleRow
label={t('fastingMilestonesLabel')}
description={t('fastingMilestonesHint')}
checked={cfg.settings.fastingMilestones}
onChange={(v) => handleToggle('fastingMilestones', v)}
disabled={pending || !pushReady}
comingSoon
/>
</div>
<aside className="md:border-ink/15 space-y-5 md:col-span-5 md:border-l md:pl-8">
<p className="editorial-eyebrow">{t('thisDevice')}</p>
{pushReady ? (
<>
<p className="font-display text-ink text-base italic leading-snug">
{t('thisDeviceActive')}
</p>
<Button type="button" variant="outline" onClick={handleTest} disabled={pending}>
{pending ? t('working') : t('sendTest')}
</Button>
</>
) : blocked ? (
<p className="font-display text-pomodoro text-base italic leading-snug">
{t('blocked')}
</p>
) : (
<>
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('thisDeviceInactive')}
</p>
<Button type="button" onClick={handleSubscribe} disabled={pending}>
{pending ? t('working') : t('enableHere')}
</Button>
</>
)}
</aside>
</div>
)}
{cfg.devices.length > 0 ? (
<div className="space-y-4">
<p className="editorial-eyebrow">{t('registeredDevices')}</p>
<ul className="divide-ink/10 divide-y">
{cfg.devices.map((d) => (
<DeviceRow
key={d.id}
device={d}
onRemove={(id) => handleUnsubscribe(id)}
disabled={pending}
/>
))}
</ul>
</div>
) : null}
{feedback ? (
<p className="font-display text-oliva text-base italic" role="status">
{feedback}
</p>
) : null}
{error ? (
<p className="font-display text-pomodoro text-base italic" role="alert">
{error}
</p>
) : null}
</section>
);
}
function ToggleRow({
label,
description,
checked,
onChange,
disabled,
comingSoon,
}: {
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
comingSoon?: boolean;
}) {
return (
<label className="border-ink/10 flex cursor-pointer items-baseline gap-4 border-b py-4 last:border-b-0">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled || comingSoon}
className="border-ink mt-1 h-4 w-4 cursor-pointer accent-current disabled:cursor-not-allowed disabled:opacity-40"
/>
<span className="flex-1">
<span className="font-display text-ink flex items-baseline gap-2 text-lg leading-tight">
{label}
{comingSoon ? (
<span className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
· in arrivo
</span>
) : null}
</span>
<span className="font-display text-ink-soft mt-1 block text-sm italic leading-snug">
{description}
</span>
</span>
</label>
);
}
function DeviceRow({
device,
onRemove,
disabled,
}: {
device: NotificationDevice;
onRemove: (id: string) => void;
disabled?: boolean;
}) {
const ua = device.userAgent ?? '';
const label = describeUserAgent(ua) || device.platform;
return (
<li className="grid grid-cols-[1fr_auto] items-baseline gap-4 py-3">
<div>
<p className="font-display text-ink text-base leading-tight">{label}</p>
<p className="text-ink-soft mt-1 font-mono text-[10px] uppercase tracking-widest">
{device.platform} · {FORMATTER.format(new Date(device.createdAt))}
</p>
</div>
<button
type="button"
onClick={() => onRemove(device.id)}
disabled={disabled}
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors disabled:opacity-40"
>
Rimuovi
</button>
</li>
);
}
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;
}
+12 -1
View File
@@ -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<typeof ProfileForm>[0]['initial'];
const notifications = await fetchNotificationConfig();
return <ProfilePageContent initial={profile} />;
return <ProfilePageContent initial={profile} notifications={notifications} />;
}
function ProfilePageContent({
initial,
notifications,
}: {
initial: Parameters<typeof ProfileForm>[0]['initial'];
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
}) {
const t = useTranslations('Profile');
@@ -50,6 +55,12 @@ function ProfilePageContent({
<div className="animate-fade-up [animation-delay:300ms]">
<ProfileForm initial={initial} />
</div>
<div className="rule animate-rule-in my-16 [animation-delay:480ms]" />
<div className="animate-fade-up [animation-delay:540ms]">
<NotificationsPanel initial={notifications} />
</div>
</main>
</div>
);
@@ -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<ServiceWorkerRegistration | null> {
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<PushSubscription | null> {
const reg = await ensureRegistration();
if (!reg) return null;
return reg.pushManager.getSubscription();
}
export async function subscribe(): Promise<SubscribeResult> {
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<boolean> {
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;
}