ajout de la fonctionnalité de suppression de compte

This commit is contained in:
2026-04-04 22:34:42 +02:00
parent 2bcae68eed
commit 845ec655d1
10 changed files with 170 additions and 5 deletions

24
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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' })

View File

@@ -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'),

26
src/plugins/cronJobs.ts Normal file
View File

@@ -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)`)
})
})

View File

@@ -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é.' })
})
}

View File

@@ -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<void> {
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<void> {
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 },
})
}

View File

@@ -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: {

View File

@@ -195,4 +195,56 @@ export async function sendEmailChangeConfirmation(
subject,
html,
})
}
const accountDeletionTemplates = {
fr: (url: string) => ({
subject: 'Suppression de votre compte',
html: `
<html><body>
<p>Votre compte a été programmé pour être supprimé dans 7 jours.</p>
<p>
Si vous souhaitez annuler cette suppression, cliquez sur
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
ce lien
</a>.
Ce lien est valable 7 jours.
</p>
<p>Sans action de votre part, votre compte sera définitivement supprimé.</p>
</body></html>
`,
}),
en: (url: string) => ({
subject: 'Account deletion scheduled',
html: `
<html><body>
<p>Your account has been scheduled for deletion in 7 days.</p>
<p>
If you wish to cancel this deletion, click
<a href="${url}" style="font-size:1.2em;color:blueviolet;">
this link
</a>.
This link is valid for 7 days.
</p>
<p>If you do nothing, your account will be permanently deleted.</p>
</body></html>
`,
}),
}
export async function sendAccountDeletionWarning(
mailer: Transporter,
email: string,
displayName: string,
token: string,
lang: Lang = 'fr'
): Promise<void> {
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,
})
}