diff --git a/package-lock.json b/package-lock.json index d5d250d..567c7d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,14 @@ "argon2": "^0.44.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", + "node-cron": "^4.2.1", "nodemailer": "^8.0.4", "pg": "^8.20.0", "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^25.5.0", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.11", "prisma": "^7.6.0", "tsx": "^4.21.0", @@ -1100,6 +1102,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/nodemailer": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", @@ -1405,9 +1414,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", + "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", "devOptional": true, "license": "MIT" }, @@ -2061,6 +2070,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", diff --git a/package.json b/package.json index 1889b98..3c4ac3d 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,14 @@ "argon2": "^0.44.0", "fastify": "^5.8.4", "fastify-plugin": "^5.1.0", + "node-cron": "^4.2.1", "nodemailer": "^8.0.4", "pg": "^8.20.0", "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^25.5.0", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.11", "prisma": "^7.6.0", "tsx": "^4.21.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2bb4f95..8d25dd3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,7 +31,9 @@ model User { encryptedPrivateKey String? emailSwap String? emailSwapAt DateTime? + scheduledDeletionAt DateTime? createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt ActionToken ActionToken[] UserPreference UserPreference? } diff --git a/src/app.ts b/src/app.ts index dd227d9..3e9212b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import mailerPlugin from './plugins/mailer.js' import authRoutes from './routes/auth.js' import userRoutes from './routes/users.js' import errorHandler from './plugins/errorHandler.js' +import cronJobs from './plugins/cronJobs.js' export default function buildApp() { const app = Fastify({ logger: true }) @@ -17,7 +18,8 @@ export default function buildApp() { }) app.register(prismaPlugin) app.register(mailerPlugin) - + app.register(cronJobs) + app.register(authRoutes, { prefix: '/api' }) app.register(userRoutes, { prefix: '/api' }) diff --git a/src/errors/AppError.ts b/src/errors/AppError.ts index 485cf5f..f15a177 100644 --- a/src/errors/AppError.ts +++ b/src/errors/AppError.ts @@ -26,7 +26,9 @@ export const Errors = { // Email change errors EMAIL_ALREADY_YOURS: new AppError('EMAIL_ALREADY_YOURS', 409, 'This email is already yours'), EMAIL_ALREADY_USED: new AppError('EMAIL_ALREADY_USED', 409, 'This email is already in use'), - + //acount deletion errors + ACCOUNT_PENDING_DELETION: new AppError('ACCOUNT_PENDING_DELETION', 403, 'Account is scheduled for deletion. Check your emails to cancel.'), + ACCOUNT_ALREADY_PENDING_DELETION: new AppError('ACCOUNT_ALREADY_PENDING_DELETION', 409, 'Account is already scheduled for deletion.'), //Auth errors UNAUTHORIZED: new AppError('UNAUTHORIZED', 401, 'Non authentifié'), USER_NOT_FOUND: new AppError('USER_NOT_FOUND', 404, 'Utilisateur introuvable'), diff --git a/src/plugins/cronJobs.ts b/src/plugins/cronJobs.ts new file mode 100644 index 0000000..aa4e597 --- /dev/null +++ b/src/plugins/cronJobs.ts @@ -0,0 +1,26 @@ +import fp from 'fastify-plugin' +import { FastifyInstance } from 'fastify' +import cron from 'node-cron' + +export default fp(async (fastify: FastifyInstance) => { + // Cleaning all nights at 2am + cron.schedule('0 2 * * *', async () => { + fastify.log.info('Cron: démarrage du nettoyage nocturne') + + // Suppression des comptes dont la date de suppression est dépassée + const deletedAccounts = await fastify.prisma.user.deleteMany({ + where: { + scheduledDeletionAt: { lte: new Date() }, + }, + }) + fastify.log.info(`Cron: ${deletedAccounts.count} compte(s) supprimé(s)`) + + // Nettoyage des tokens expirés + const deletedTokens = await fastify.prisma.actionToken.deleteMany({ + where: { + expiresAt: { lte: new Date() }, + }, + }) + fastify.log.info(`Cron: ${deletedTokens.count} token(s) expiré(s) supprimé(s)`) + }) +}) \ No newline at end of file diff --git a/src/routes/users.ts b/src/routes/users.ts index d45c136..40edf04 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -7,6 +7,7 @@ import { updateDisplayName } from '../services/user.service.js' import { passwordRecoveryRequest, confirmPasswordRecovery, changePassword } from '../services/passwordManagement.service.js' import { EmailChangeRequestSchema } from '../schemas/user.schema.js' import { emailChangeRequest, emailChangeConfirm, emailRollback } from '../services/emailChange.service.js' +import { scheduleAccountDeletion, cancelAccountDeletion } from '../services/accountDeletion.service.js' export default async function userRoutes(fastify: FastifyInstance) { fastify.get('/users', async (request, reply) => { @@ -111,4 +112,17 @@ export default async function userRoutes(fastify: FastifyInstance) { return reply.status(200).send({ message: 'Email restauré avec succès.' }) }) + + fastify.delete('/user/account', { preHandler: verifyAuth }, async (request, reply) => { + await scheduleAccountDeletion(fastify.prisma, fastify.mailer, request.user.userId) + return reply.status(200).send({ message: 'Votre compte sera supprimé dans 7 jours.' }) + }) + + fastify.get('/user/delete-cancel', async (request, reply) => { + const { token } = request.query as { token?: string } + if (!token) throw Errors.INVALID_TOKEN + + await cancelAccountDeletion(fastify.prisma, token) + return reply.status(200).send({ message: 'Suppression annulée, votre compte est restauré.' }) + }) } \ No newline at end of file diff --git a/src/services/accountDeletion.service.ts b/src/services/accountDeletion.service.ts new file mode 100644 index 0000000..8be6cf2 --- /dev/null +++ b/src/services/accountDeletion.service.ts @@ -0,0 +1,46 @@ +import { PrismaClient } from '../generated/prisma/client.js' +import { Transporter } from 'nodemailer' +import { Errors } from '../errors/AppError.js' +import { createActionToken, consumeActionToken } from './actionToken.service.js' +import { sendAccountDeletionWarning } from './mailing.service.js' + +export async function scheduleAccountDeletion( + prisma: PrismaClient, + mailer: Transporter, + userId: string, + lang: 'fr' | 'en' = 'fr' +): Promise { + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) throw Errors.USER_NOT_FOUND + if (user.scheduledDeletionAt) throw Errors.ACCOUNT_ALREADY_PENDING_DELETION + + const scheduledDeletionAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + + await prisma.user.update({ + where: { id: userId }, + data: { scheduledDeletionAt }, + }) + + const cancelToken = await createActionToken(prisma, userId, 'delete-cancel', 7 * 24 * 60) + + await sendAccountDeletionWarning(mailer, user.email, user.displayName, cancelToken, lang) +} + +export async function cancelAccountDeletion( + prisma: PrismaClient, + token: string +): Promise { + const actionToken = await consumeActionToken(prisma, token, 'delete-cancel') + + const user = await prisma.user.findUnique({ + where: { id: actionToken.userId }, + select: { scheduledDeletionAt: true }, + }) + + if (!user?.scheduledDeletionAt) throw Errors.INVALID_TOKEN + + await prisma.user.update({ + where: { id: actionToken.userId }, + data: { scheduledDeletionAt: null }, + }) +} \ No newline at end of file diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 6e570ea..2e1edb9 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -59,6 +59,7 @@ export async function loginUser(prisma: PrismaClient, input: LoginInput) { const valid = await argon2.verify(user.passwordHash, input.password) if (!valid) throw Errors.INVALID_CREDENTIALS + if (user.scheduledDeletionAt) throw Errors.ACCOUNT_PENDING_DELETION return { user: { diff --git a/src/services/mailing.service.ts b/src/services/mailing.service.ts index 78dffd6..edd8124 100644 --- a/src/services/mailing.service.ts +++ b/src/services/mailing.service.ts @@ -195,4 +195,56 @@ export async function sendEmailChangeConfirmation( subject, html, }) +} + +const accountDeletionTemplates = { + fr: (url: string) => ({ + subject: 'Suppression de votre compte', + html: ` + +

Votre compte a été programmé pour être supprimé dans 7 jours.

+

+ Si vous souhaitez annuler cette suppression, cliquez sur + + ce lien + . + Ce lien est valable 7 jours. +

+

Sans action de votre part, votre compte sera définitivement supprimé.

+ + `, + }), + en: (url: string) => ({ + subject: 'Account deletion scheduled', + html: ` + +

Your account has been scheduled for deletion in 7 days.

+

+ If you wish to cancel this deletion, click + + this link + . + This link is valid for 7 days. +

+

If you do nothing, your account will be permanently deleted.

+ + `, + }), +} + +export async function sendAccountDeletionWarning( + mailer: Transporter, + email: string, + displayName: string, + token: string, + lang: Lang = 'fr' +): Promise { + const url = `${process.env.FRONT_URL}/${lang}/delete-cancel?token=${token}` + const { subject, html } = accountDeletionTemplates[lang](url) + await mailer.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject, + html, + }) } \ No newline at end of file