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

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