Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion srcs/auth/src/controllers/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export async function deleteUserHandler(
}

const targetUsername = targetUser.username;
authService.deleteUserAsAdmin(targetUserId);
await authService.deleteUser(targetUserId);

logger.info({
event: 'admin_delete_user_success',
Expand Down
68 changes: 68 additions & 0 deletions srcs/auth/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,3 +938,71 @@ export async function isUserOnlineHandler(
});
}
}

/**
* Suppression du compte utilisateur
* Permet à un utilisateur de supprimer définitivement son compte
*/
export async function deleteUserHandler(
this: FastifyInstance,
req: FastifyRequest,
reply: FastifyReply,
) {
try {
// ID utilisateur headers
const idHeader = (req.headers as any)['x-user-id'];
const userId = idHeader ? Number(idHeader) : null;
const username = (req.headers as any)['x-user-name'] || null;

if (!userId) {
this.log.warn({
event: 'delete_user_unauthorized',
username,
});
return reply.code(HTTP_STATUS.UNAUTHORIZED).send({
error: {
message: ERROR_MESSAGES.UNAUTHORIZED,
code: ERROR_CODES.UNAUTHORIZED,
},
});
}

this.log.info({
event: 'delete_user_request',
userId,
username,
});

await authService.deleteUser(userId);

this.log.info({
event: 'delete_user_success',
userId,
username,
});

return reply.code(HTTP_STATUS.NO_CONTENT).send();
} catch (error: unknown) {
this.log.error({
event: 'delete_user_error',
error: (error as Error)?.message || error,
});

if (error instanceof AppError) {
const frontendError = mapToFrontendError(error);
return reply.code(frontendError.statusCode).send({
error: {
message: frontendError.message,
code: frontendError.code,
},
});
}

return reply.code(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
error: {
message: 'Internal server error',
code: ERROR_CODES.INTERNAL_ERROR,
},
});
}
}
15 changes: 15 additions & 0 deletions srcs/auth/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
disable2FAHandler,
heartbeatHandler,
isUserOnlineHandler,
deleteUserHandler,
} from '../controllers/auth.controller.js';
import { AUTH_CONFIG } from '../utils/constants.js';

Expand Down Expand Up @@ -122,6 +123,20 @@ export async function authRoutes(app: FastifyInstance) {

app.post('/2fa/disable', disable2FAHandler);

// Suppression du compte utilisateur
app.delete(
'/user/delete',
{
config: {
rateLimit: {
max: AUTH_CONFIG.RATE_LIMIT.DELETE_USER.max,
timeWindow: AUTH_CONFIG.RATE_LIMIT.DELETE_USER.timeWindow,
},
},
},
deleteUserHandler,
);

app.get(
'/is-online/:name',
{
Expand Down
50 changes: 38 additions & 12 deletions srcs/auth/src/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import bcrypt from 'bcrypt';
import * as db from './database.js';
import { createUserProfile } from './external/um.service.js';
import { createUserProfile, deleteUserProfile } from './external/um.service.js';
import { DataError, ServiceError } from '../types/errors.js';
import { APP_ERRORS } from '../utils/error-catalog.js';
import { EVENTS, REASONS, UserRole } from '../utils/constants.js';
import { authenv } from '../config/env.js';
import { logger } from '../index.js';
import { AppError, ERR_DEFS } from '@transcendence/core';
import * as onlineService from './online.service.js';

const SALT_ROUNDS = 10;

Expand Down Expand Up @@ -105,17 +106,6 @@ export function updateUserAsAdmin(
}
}

export function deleteUserAsAdmin(userId: number) {
try {
db.deleteUser(userId);
} catch (err: unknown) {
if (err instanceof DataError) {
throw new ServiceError(APP_ERRORS.LOGIN_USER_NOT_FOUND);
}
throw err;
}
}

export function adminDisable2FA(userId: number) {
try {
db.disable2FA(userId);
Expand Down Expand Up @@ -228,3 +218,39 @@ export function hasRole(userId: number, requiredRole: UserRole): boolean {
return false;
}
}

/**
* Supprime un utilisateur et toutes ses données
* Suppression entre les services auth et users
*/
export async function deleteUser(userId: number): Promise<void> {
logger.info({ event: 'delete_user_start', userId });

try {
// Supp user profile UM service
await deleteUserProfile(userId);

// Supp user Redis online
await onlineService.removeUserFromRedis(userId);

// Supp user from auth DB
db.deleteUser(userId);

logger.info({ event: 'delete_user_completed', userId });
} catch (error: unknown) {
logger.error({ event: 'delete_user_failed', userId, error: (error as Error)?.message });

if (error instanceof AppError) {
throw error;
}

if (error instanceof DataError) {
throw new AppError(ERR_DEFS.RESOURCE_NOT_FOUND, { userId });
}

throw new AppError(ERR_DEFS.SERVICE_GENERIC, {
userId,
originalError: (error as Error)?.message,
});
}
}
49 changes: 49 additions & 0 deletions srcs/auth/src/services/external/um.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,52 @@ export async function createUserProfile(payload: CreateProfileDTO): Promise<User
throw new ServiceError(APP_ERRORS.SERVICE_UNAVAILABLE, { originalError: error });
}
}

/**
* Supprime un profil utilisateur via le service users
*/
export async function deleteUserProfile(userId: number): Promise<void> {
try {
logger.info({ msg: `calling DELETE ${UM_SERVICE_URL}/users/${userId}` });

// Configuration de la requête avec l'agent mTLS
const init: MTLSRequestInit = {
method: 'DELETE',
headers: {
'x-user-id': String(userId),
},
dispatcher: mtlsAgent,
};

const response = await fetch(`${UM_SERVICE_URL}/users/${userId}`, init);

if (!response.ok) {
const errorText = await response.text();

let parsedMessage = 'Failed to delete user profile';
try {
const parsed = JSON.parse(errorText);
parsedMessage = parsed?.message || parsedMessage;
} catch {
// keep fallback
}

throw new ServiceError(
{
code: ERROR_CODES.INTERNAL_ERROR,
event: EVENTS.DEPENDENCY.FAIL,
statusCode: response.status,
reason: REASONS.NETWORK.UPSTREAM_ERROR,
message: parsedMessage,
},
{ originalError: { status: response.status, body: errorText } },
);
}

logger.info({ msg: `user profile deleted successfully`, userId });
} catch (error) {
logger.error({ msg: `error DELETE ${UM_SERVICE_URL}/users/${userId}`, error: error });
if (error instanceof ServiceError) throw error;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on peut utiliser AppError : c'était trop de distinguer les DataError et ServiceError

throw new ServiceError(APP_ERRORS.SERVICE_UNAVAILABLE, { originalError: error });
}
}
29 changes: 29 additions & 0 deletions srcs/auth/src/services/online.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,32 @@ export async function closeRedis(): Promise<void> {
logger.info({ event: 'redis_connection_closed' });
}
}

/**
* Supprime toutes les données Redis d'un utilisateur
*/
export async function removeUserFromRedis(userId: number): Promise<void> {
try {
const client = getRedisClient();
const userKey = `${ONLINE_KEY_PREFIX}${userId}`;

// Supp user online key
await client.del(userKey);

// Supp user du set des utilisateurs en ligne
await client.srem(ONLINE_USERS_SET, userId.toString());

logger.info({
event: 'user_redis_cleanup',
userId,
message: 'User data removed from Redis',
});
} catch (error) {
logger.error({
event: 'user_redis_cleanup_error',
userId,
error: (error as Error)?.message,
});
throw error;
}
Comment on lines +251 to +274
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operationally, failing Redis cleanup currently aborts account deletion because this function rethrows. Since online status is ephemeral and other Redis reads (e.g. isUserOnline) degrade gracefully on Redis errors, consider making removeUserFromRedis best-effort (log + continue) or returning a typed error that deleteUser can treat as non-blocking.

Copilot uses AI. Check for mistakes.
}
4 changes: 4 additions & 0 deletions srcs/auth/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ export const AUTH_CONFIG = {
max: isTestOrDev ? 1000 : 2000,
timeWindow: '1 minute',
},
DELETE_USER: {
max: isTestOrDev ? 100 : 5,
timeWindow: '1 minute',
},
},
} as const;

Expand Down
9 changes: 9 additions & 0 deletions srcs/users/src/controllers/profiles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ export class ProfileController {
const profileSimpleDTO = await profileService.deleteByUsername(username);
return reply.status(200).send(profileSimpleDTO);
}

async deleteProfileById(req: FastifyRequest, reply: FastifyReply) {
const { userId } = req.params as {
userId: number;
};
req.log.trace({ event: `${LOG_ACTIONS.DELETE}_${LOG_RESOURCES.PROFILE}`, param: userId });
await profileService.deleteById(userId);
return reply.status(204).send();
}
}

export const profileController = new ProfileController();
20 changes: 20 additions & 0 deletions srcs/users/src/routes/profiles.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ const deleteProfileSchema = {
},
} as const;

const deleteProfileByIdSchema = {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

tags: ['users'],
summary: 'Delete a profile by user ID',
description: 'Delete a profile by user ID (internal service use)',
params: z.object({
userId: z.coerce.number(),
}),
response: {
204: z.void(),
400: ValidationErrorSchema,
404: DetailedErrorSchema,
},
} as const;

export const umRoutes: FastifyPluginAsyncZod = async (app) => {
app.get(
'/health',
Expand Down Expand Up @@ -104,4 +118,10 @@ export const umRoutes: FastifyPluginAsyncZod = async (app) => {
{ schema: deleteProfileSchema },
profileController.deleteProfile,
);

app.delete(
'/users/:userId',
{ schema: deleteProfileByIdSchema },
profileController.deleteProfileById,
);
};
14 changes: 14 additions & 0 deletions srcs/users/src/services/profiles.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ export class ProfileService {
const deletedProfile = await profileRepository.deleteProfile(profile.authId);
return deletedProfile;
}

@Trace
async deleteById(authId: number): Promise<void> {
const profile = await profileRepository.findProfileById(authId);
if (!profile) {
throw new AppError(ERR_DEFS.RESOURCE_NOT_FOUND, {
details: {
resource: LOG_RESOURCES.PROFILE,
id: authId,
},
});
}
await profileRepository.deleteProfile(authId);
}
}

export const profileService = new ProfileService();