feat: GDPR (export+delete) + catalogo ricette 46→96

GDPR (PRD §14, art. 17 + 20)
- GET /me/export.json: dump completo dei dati personali
  (User, Profile, Preferences, WeightEntry, FastEvent, MealPlan+slots,
  DailyCheckIn, DeviceToken). I campi @encrypted vengono decifrati a runtime
  dall'extension Prisma. Risposta con Content-Disposition attachment.
- DELETE /me: cancellazione account con cascade automatico. Per gli
  account email/password richiede conferma password (verifica via
  auth.api.signInEmail). Account OAuth-only: solo sessione attiva.
- Proxy Next /api/gdpr-export per same-origin + cookie sessione.
- /profile: nuova sezione "I tuoi dati (GDPR)" in fondo con due azioni
  (esporta + elimina) e modale di conferma password per la cancellazione.

Catalogo ricette 46 → 96 (target 100)
- 50 ricette nuove italiane keto/low-carb bilanciate per categoria:
  12 colazioni, 14 pranzi, 12 spuntini, 12 cene
- Macros realistici, ingredienti dal seed esistente (no nuovi ingredienti
  necessari)
- Run db:seed: ingredients +0 (catalog 55), recipes +49 (catalog 96),
  recipe-ingredient links: 323

i18n: namespace Profile esteso con dataEyebrow/Title/Subtitle, dataExport*,
dataDelete* (incluso typed errors map), working.

81/81 unit test verdi. Lint, typecheck verdi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-30 00:24:53 +02:00
1 parent d8f2a17888
commit 60a3d072f6
8 files changed
+1206

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ import { env } from './config/env.js';
import { Sentry } from './lib/sentry.js';
import { dbRoutes } from './modules/db/db.routes.js';
import { healthRoutes } from './modules/health/health.routes.js';
import { gdprRoutes } from './modules/me/gdpr.routes.js';
import { meRoutes } from './modules/me/me.routes.js';
import { notificationsRoutes } from './modules/notifications/notifications.routes.js';
import { planExportRoutes } from './modules/plan/export.routes.js';
@@ -54,6 +55,7 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(healthRoutes);
await app.register(dbRoutes);
await app.register(meRoutes);
await app.register(gdprRoutes);
await app.register(profileRoutes);
await app.register(preferencesRoutes);
await app.register(weightRoutes);
+148
View File
@@ -0,0 +1,148 @@
// PRD §14 — Diritto all'oblio (art. 17 GDPR) + portabilità (art. 20).
//
// `/me/export.json` restituisce un dump completo dei dati personali in JSON.
// I campi @encrypted vengono decifrati a runtime dal Prisma extension, quindi
// arrivano qui in chiaro: l'export deve essere autenticato e con
// Content-Disposition attachment.
//
// `DELETE /me` cancella l'utente e tutti i dati collegati via cascade. Per
// evitare cancellazioni accidentali richiediamo conferma re-inserendo la
// password (per gli account email/password); per gli OAuth-only basta la
// sessione attiva.
import { auth } from '@ketopath/auth';
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
export const gdprRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/export.json', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
const [
user,
profile,
preferences,
weightEntries,
fastEvents,
mealPlans,
checkIns,
deviceTokens,
] = await Promise.all([
fastify.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
emailVerified: true,
name: true,
image: true,
role: true,
disclaimerAcceptedAt: true,
phase2StartedAt: true,
fastingPausedUntil: true,
createdAt: true,
updatedAt: true,
},
}),
fastify.prisma.profile.findUnique({ where: { userId } }),
fastify.prisma.preferences.findUnique({ where: { userId } }),
fastify.prisma.weightEntry.findMany({
where: { userId },
orderBy: { date: 'asc' },
}),
fastify.prisma.fastEvent.findMany({
where: { userId },
orderBy: { startedAt: 'asc' },
}),
fastify.prisma.mealPlan.findMany({
where: { userId },
orderBy: { weekStart: 'asc' },
include: {
slots: {
include: {
selected: { select: { id: true, name: true } },
alternatives: { select: { id: true, name: true } },
},
},
},
}),
fastify.prisma.dailyCheckIn.findMany({
where: { userId },
orderBy: { date: 'asc' },
}),
fastify.prisma.deviceToken.findMany({
where: { userId },
select: {
id: true,
platform: true,
userAgent: true,
createdAt: true,
lastSeenAt: true,
// endpoint/p256dh/auth omessi: sono token push, non dato personale
},
}),
]);
const payload = {
exportedAt: new Date().toISOString(),
schemaVersion: '1.0',
user,
profile,
preferences,
weightEntries,
fastEvents,
mealPlans,
dailyCheckIns: checkIns,
deviceTokens,
};
return reply
.header('Content-Type', 'application/json; charset=utf-8')
.header('Content-Disposition', 'attachment; filename="ketopath-export.json"')
.send(payload);
});
// DELETE /me — cancellazione account.
// Body opzionale { password } per ulteriore conferma sull'account
// email/password. Il cascade del modello rimuove tutto il resto.
fastify.delete('/me', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
const body = (request.body ?? {}) as { password?: unknown };
// Se l'utente ha un account credential, esigiamo la password per
// confermare la cancellazione. Per OAuth-only saltiamo il check.
const credentialAccount = await fastify.prisma.account.findFirst({
where: { userId, providerId: 'credential' },
select: { password: true },
});
if (credentialAccount && credentialAccount.password) {
if (typeof body.password !== 'string' || body.password.length === 0) {
return reply.code(400).send({ error: 'password_required' });
}
const email = request.user!.email;
if (!email) return reply.code(400).send({ error: 'email_required' });
// Better Auth: verifica la password tentando un sign-in. Se la password
// non corrisponde lancia un errore. Non emettiamo cookie reply.
try {
await auth.api.signInEmail({
body: { email, password: body.password },
});
} catch {
return reply.code(401).send({ error: 'invalid_password' });
}
}
// Hard-delete con cascade.
await fastify.prisma.user.delete({ where: { id: userId } });
request.log.info({ userId }, '[gdpr] user account deleted');
// Cookie di sessione: l'utente è già stato cancellato (cascade) ma il
// browser potrebbe ancora avere il cookie. Lo invalidiamo via API.
return reply
.header('Set-Cookie', 'ketopath.session_token=; Path=/; Max-Age=0; HttpOnly')
.code(204)
.send();
});
};