feat: web push notifications, mobile-ready (PRD §5.6)
ADR 0003: VAPID self-hosted today, DeviceToken model agnostic to
platform so iOS/Android (Expo/APNs/FCM) plug in as new senders later.
Backend (apps/api/src/modules/notifications)
- sender.ts: NotificationSender interface, WebPushSender via VAPID
- notifications.routes.ts: GET /me/notifications/config, POST/DELETE
/me/device-tokens, PATCH /me/notifications/settings, POST /me/notifications/test
- scheduler.ts: node-cron Mon 09:00 Europe/Rome for weekly weigh-in
reminder; auto-cleanup of expired tokens on 404/410
- env: VAPID_PUBLIC_KEY/PRIVATE_KEY/SUBJECT (all optional → push gracefully off)
Frontend
- public/sw.js minimal (push + notificationclick)
- lib/notifications/push-client.ts: subscribe / unsubscribe / getCurrentSubscription
- profile/notifications-{actions,panel}.tsx: editorial panel with toggles,
device list, "send test", per-device removal
- pushReady requires both permission AND active subscription (covers the
case where the user revoked the SW but kept the browser permission)
Schema
- DeviceToken { userId, platform, endpoint, p256dh, auth, token, userAgent,
createdAt, lastSeenAt } with unique(userId, endpoint)
- ExtendedPrismaClient type exported from @ketopath/db
- NotificationSettings zod schema in @ketopath/shared
Tooling
- lint-staged: split .js out of eslint glob so service worker is only
formatted (it lives outside the TS project)
i18n
- Notifications namespace (it) with typed error keys
Smoke tested: POST /me/device-tokens 201, POST /me/notifications/test 200,
real push delivered to a macOS Chrome device.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cdfc685855
commit
bb48357179
26 files changed
+1144
-2
No files matched your search
@@ -10,5 +10,8 @@ module.exports = {
|
|||||||
'out/',
|
'out/',
|
||||||
'coverage/',
|
'coverage/',
|
||||||
'pnpm-lock.yaml',
|
'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',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -23,3 +23,11 @@ BETTER_AUTH_URL=http://localhost:3000
|
|||||||
|
|
||||||
# Sentry — error tracking (lascia vuoto per disabilitare)
|
# Sentry — error tracking (lascia vuoto per disabilitare)
|
||||||
# SENTRY_DSN=
|
# 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
|
||||||
@@ -22,12 +22,16 @@
|
|||||||
"better-auth": "^1.0.21",
|
"better-auth": "^1.0.21",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fastify-plugin": "^4.5.1",
|
"fastify-plugin": "^4.5.1",
|
||||||
|
"node-cron": "^3.0",
|
||||||
|
"web-push": "^3.6",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ketopath/eslint-config": "workspace:*",
|
"@ketopath/eslint-config": "workspace:*",
|
||||||
"@ketopath/tsconfig": "workspace:*",
|
"@ketopath/tsconfig": "workspace:*",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
|
"@types/node-cron": "^3.0",
|
||||||
|
"@types/web-push": "^3.6",
|
||||||
"pino-pretty": "^11.2.1",
|
"pino-pretty": "^11.2.1",
|
||||||
"tsx": "^4.16.2"
|
"tsx": "^4.16.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Sentry } from './lib/sentry.js';
|
|||||||
import { dbRoutes } from './modules/db/db.routes.js';
|
import { dbRoutes } from './modules/db/db.routes.js';
|
||||||
import { healthRoutes } from './modules/health/health.routes.js';
|
import { healthRoutes } from './modules/health/health.routes.js';
|
||||||
import { meRoutes } from './modules/me/me.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 { planRoutes } from './modules/plan/plan.routes.js';
|
||||||
import { profileRoutes } from './modules/profile/profile.routes.js';
|
import { profileRoutes } from './modules/profile/profile.routes.js';
|
||||||
import { shoppingRoutes } from './modules/shopping/shopping.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(fastRoutes);
|
||||||
await app.register(planRoutes);
|
await app.register(planRoutes);
|
||||||
await app.register(shoppingRoutes);
|
await app.register(shoppingRoutes);
|
||||||
|
await app.register(notificationsRoutes);
|
||||||
|
|
||||||
if (env.SENTRY_DSN) {
|
if (env.SENTRY_DSN) {
|
||||||
app.setErrorHandler((err, request, reply) => {
|
app.setErrorHandler((err, request, reply) => {
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ const envSchema = z.object({
|
|||||||
BETTER_AUTH_SECRET: z.string().min(32),
|
BETTER_AUTH_SECRET: z.string().min(32),
|
||||||
BETTER_AUTH_URL: z.string().url(),
|
BETTER_AUTH_URL: z.string().url(),
|
||||||
SENTRY_DSN: z.string().url().optional(),
|
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>;
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,16 @@
|
|||||||
import './lib/sentry.js';
|
import './lib/sentry.js';
|
||||||
import { buildApp } from './app.js';
|
import { buildApp } from './app.js';
|
||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
|
import { startNotificationScheduler } from './modules/notifications/scheduler.js';
|
||||||
|
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
const app = await buildApp();
|
const app = await buildApp();
|
||||||
|
|
||||||
|
const cronTask = startNotificationScheduler(app.prisma, app.log);
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
cronTask?.stop();
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.listen({ port: env.PORT, host: env.HOST });
|
await app.listen({ port: env.PORT, host: env.HOST });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -26,3 +26,8 @@ PRISMA_FIELD_ENCRYPTION_KEY=
|
|||||||
# SENTRY_AUTH_TOKEN=
|
# SENTRY_AUTH_TOKEN=
|
||||||
# SENTRY_ORG=
|
# SENTRY_ORG=
|
||||||
# SENTRY_PROJECT=
|
# 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=
|
||||||
@@ -2,4 +2,8 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
extends: ['@ketopath/eslint-config/nextjs.cjs'],
|
extends: ['@ketopath/eslint-config/nextjs.cjs'],
|
||||||
|
ignorePatterns: [
|
||||||
|
// Service worker servito al browser: out-of-project per il TS parser.
|
||||||
|
'public/**/*.js',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
@@ -165,6 +165,40 @@
|
|||||||
"delta": "Variazione",
|
"delta": "Variazione",
|
||||||
"emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate."
|
"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": {
|
"Shopping": {
|
||||||
"title": "La spesa della settimana",
|
"title": "La spesa della settimana",
|
||||||
"subtitle": "Tutto ciò che ti serve per il piano in corso, raggruppato per reparto.",
|
"subtitle": "Tutto ciò che ti serve per il piano in corso, raggruppato per reparto.",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { Masthead } from '@/components/masthead';
|
|||||||
import { getServerSession } from '@/lib/auth';
|
import { getServerSession } from '@/lib/auth';
|
||||||
|
|
||||||
import { fetchProfile } from './actions';
|
import { fetchProfile } from './actions';
|
||||||
|
import { fetchNotificationConfig } from './notifications-actions';
|
||||||
|
import { NotificationsPanel } from './notifications-panel';
|
||||||
import { ProfileForm } from './profile-form';
|
import { ProfileForm } from './profile-form';
|
||||||
|
|
||||||
export default async function ProfilePage({ params: { locale } }: { params: { locale: string } }) {
|
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');
|
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||||
|
|
||||||
const profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
|
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({
|
function ProfilePageContent({
|
||||||
initial,
|
initial,
|
||||||
|
notifications,
|
||||||
}: {
|
}: {
|
||||||
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
||||||
|
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('Profile');
|
const t = useTranslations('Profile');
|
||||||
|
|
||||||
@@ -50,6 +55,12 @@ function ProfilePageContent({
|
|||||||
<div className="animate-fade-up [animation-delay:300ms]">
|
<div className="animate-fade-up [animation-delay:300ms]">
|
||||||
<ProfileForm initial={initial} />
|
<ProfileForm initial={initial} />
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
+4
-1
@@ -47,10 +47,13 @@
|
|||||||
"@vitest/coverage-v8": "^1.6.0"
|
"@vitest/coverage-v8": "^1.6.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,jsx,cjs,mjs}": [
|
"*.{ts,tsx,jsx,cjs,mjs}": [
|
||||||
"eslint --fix --max-warnings=0",
|
"eslint --fix --max-warnings=0",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
],
|
],
|
||||||
|
"*.js": [
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
"*.{json,md,yml,yaml,css}": [
|
"*.{json,md,yml,yaml,css}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -96,6 +96,7 @@ model User {
|
|||||||
weightEntries WeightEntry[]
|
weightEntries WeightEntry[]
|
||||||
fastEvents FastEvent[]
|
fastEvents FastEvent[]
|
||||||
mealPlans MealPlan[]
|
mealPlans MealPlan[]
|
||||||
|
deviceTokens DeviceToken[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -319,6 +320,31 @@ model MealPlan {
|
|||||||
@@map("meal_plans")
|
@@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 {
|
model MealSlot {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
planId String @map("plan_id")
|
planId String @map("plan_id")
|
||||||
|
|||||||
@@ -17,3 +17,7 @@ if (process.env.NODE_ENV !== 'production') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const prisma = baseClient.$extends(fieldEncryptionExtension());
|
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;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { prisma } from './client.js';
|
export { prisma } from './client.js';
|
||||||
|
export type { ExtendedPrismaClient } from './client.js';
|
||||||
export {
|
export {
|
||||||
ActivityLevel,
|
ActivityLevel,
|
||||||
CookingTime,
|
CookingTime,
|
||||||
@@ -14,11 +15,13 @@ export {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
export type {
|
export type {
|
||||||
Account,
|
Account,
|
||||||
|
DeviceToken,
|
||||||
FastEvent,
|
FastEvent,
|
||||||
Ingredient,
|
Ingredient,
|
||||||
MealPlan,
|
MealPlan,
|
||||||
MealSlot,
|
MealSlot,
|
||||||
Preferences,
|
Preferences,
|
||||||
|
PrismaClient,
|
||||||
Profile,
|
Profile,
|
||||||
Recipe,
|
Recipe,
|
||||||
RecipeIngredient,
|
RecipeIngredient,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './notifications/schema.js';
|
||||||
export * from './nutrition/bmr.js';
|
export * from './nutrition/bmr.js';
|
||||||
export * from './nutrition/tdee.js';
|
export * from './nutrition/tdee.js';
|
||||||
export * from './nutrition/types.js';
|
export * from './nutrition/types.js';
|
||||||
|
|||||||
@@ -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<typeof notificationSettingsSchema>;
|
||||||
|
|
||||||
|
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<typeof webPushSubscriptionSchema>;
|
||||||
Generated
BIN
Binary file not shown.
Reference in new issue
Block a user