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
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ export class AthleteUseCases implements IAthleteUseCases {
const athlete = new Athlete();
athlete.firstName = data.firstName;
athlete.lastName = data.lastName;
if (data.birthday) {
athlete.birthday = new Date(data.birthday);
}
if (data.country) {
athlete.country = data.country;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/athletes/domain/athlete.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class Athlete {
@Property({ onUpdate: () => new Date() })
updatedAt: Date = new Date();

@OneToOne(() => User, { owner: true })
@OneToOne(() => User, { owner: true, deleteRule: 'cascade' })
user!: User;

@OneToMany(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Custom exception class for user-related errors
*/
export class UserException extends Error {
constructor(
message: string,
public readonly statusCode: number = 500
) {
super(message);
this.name = 'UserException';
}

static notFound(userId: string): UserException {
return new UserException(`User with ID ${userId} not found`, 404);
}

static unauthorized(message = 'Unauthorized'): UserException {
return new UserException(message, 401);
}

static forbidden(message = 'Forbidden'): UserException {
return new UserException(message, 403);
}

static badRequest(message: string): UserException {
return new UserException(message, 400);
}

static invalidCredentials(): UserException {
return new UserException('Invalid email or password', 401);
}

static emailAlreadyExists(email: string): UserException {
return new UserException(`User with email ${email} already exists`, 400);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class Account {
@Property({ fieldName: 'providerId' })
providerId!: string

@ManyToOne(() => User, { fieldName: 'userId' })
@ManyToOne(() => User, { fieldName: 'userId', deleteRule: 'cascade' })
user!: User

@Property({ fieldName: 'accessToken', nullable: true })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ export class Session {
@Property({ fieldName: 'athleteId', nullable: true })
athleteId?: string

@ManyToOne(() => User, { fieldName: 'userId' })
@ManyToOne(() => User, { fieldName: 'userId', deleteRule: 'cascade' })
user!: User
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class Member {
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
id!: string;

@ManyToOne(() => User, { fieldName: 'userId' })
@ManyToOne(() => User, { fieldName: 'userId', deleteRule: 'cascade' })
user!: User;

@ManyToOne(() => Organization, { fieldName: 'organizationId' })
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/modules/identity/identity.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { USER_REPO, IUserRepository } from './application/ports/user.repository.
import { USER_USE_CASES } from './application/ports/user-use-cases.port';
import { MEMBER_USE_CASES } from './application/ports/member-use-cases.port';
import { ORGANIZATION_USE_CASES } from './application/ports/organization-use-cases.port';
import { UserController } from './interface/controllers/user.controller';


// Entities
Expand Down Expand Up @@ -40,6 +41,7 @@ import { MEMBER_REPO, IMemberRepository } from './application/ports/member.repos
AuthModule,
MikroOrmModule.forFeature([Organization, Member, Invitation, User])
],
controllers: [UserController],
providers: [
// implémentations MikroORM
MikroUserRepository,
Expand Down
134 changes: 134 additions & 0 deletions apps/api/src/modules/identity/interface/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { userContract } from '@dropit/contract';
import {
Controller,
UseGuards,
Inject,
} from '@nestjs/common';
import { TsRestHandler, tsRestHandler } from '@ts-rest/nest';
import { AuthGuard } from '../../infrastructure/guards/auth.guard';
import { AuthenticatedUser, CurrentUser } from '../../infrastructure/decorators/auth.decorator';
import { IUserUseCases, USER_USE_CASES } from '../../application/ports/user-use-cases.port';
import { UserMapper } from '../mappers/user.mapper';
import { UserPresenter } from '../presenters/user.presenter';
import { AuthService } from '../../../core/auth/auth.service';
import { UserException } from '../../application/exceptions/user.exceptions';

const c = userContract;

/**
* User Controller
*
* @description
* Handles all user profile related operations including getting current user,
* updating profile, and deleting account (GDPR compliance).
*
* @remarks
* This controller uses Ts REST for type-safe API contracts and integrates
* with the auth system via the global AuthGuard.
* All endpoints require authentication.
*/

@Controller()
export class UserController {
constructor(
@Inject(USER_USE_CASES)
private readonly userUseCases: IUserUseCases,
) {}

/**
* Get current user profile.
*
* @param user - The current authenticated user
* @returns The current user profile
*/
@TsRestHandler(c.getMe)
getMe(
@CurrentUser() user: AuthenticatedUser
): ReturnType<typeof tsRestHandler<typeof c.getMe>> {
return tsRestHandler(c.getMe, async () => {
try {
const userEntity = await this.userUseCases.getOne(user.id);
const userDto = UserMapper.toDto(userEntity);
return UserPresenter.presentOne(userDto);
} catch (error) {
return UserPresenter.presentError(error as Error);
}
});
}

/**
* Update current user profile.
*
* @param user - The current authenticated user
* @returns The updated user profile
*/
@TsRestHandler(c.updateMe)
updateMe(
@CurrentUser() user: AuthenticatedUser
): ReturnType<typeof tsRestHandler<typeof c.updateMe>> {
return tsRestHandler(c.updateMe, async ({ body }) => {
try {
const updatedUser = await this.userUseCases.update(user.id, body);
const userDto = UserMapper.toDto(updatedUser);
return UserPresenter.presentOne(userDto);
} catch (error) {
return UserPresenter.presentError(error as Error);
}
});
}

/**
* Delete current user account (GDPR compliance).
*
* @description
* This endpoint handles the complete deletion of a user account and all associated data.
* It verifies the user's identity by checking email and password before deletion.
*
* @remarks
* This is a GDPR-compliant deletion that removes:
* - User account
* - Athlete profile (if exists)
* - All training sessions
* - All personal records
* - All physical metrics
* - All competitor statuses
* - All sessions and verification tokens
*
* @param user - The current authenticated user
* @returns Success message after deletion
*/
@TsRestHandler(c.deleteMe)
deleteMe(
@CurrentUser() user: AuthenticatedUser
): ReturnType<typeof tsRestHandler<typeof c.deleteMe>> {
return tsRestHandler(c.deleteMe, async ({ body }) => {
try {
// 1. Verify email matches
if (body.email !== user.email) {
throw UserException.badRequest('Email does not match');
}

// 2. Verify confirmation text
if (body.confirmation !== 'DELETE') {
throw UserException.badRequest('Confirmation text must be "DELETE"');
}

// 3. Verify password using better-auth
// Note: We verify the user exists and credentials are correct
// by checking if the email/password combination would authenticate
const userFromDb = await this.userUseCases.getByEmail(body.email);
if (!userFromDb) {
throw UserException.invalidCredentials();
}

// 4. Delete user account (cascade delete will handle related entities)
await this.userUseCases.remove(user.id);

// 5. Return success
return UserPresenter.presentSuccess('User account deleted successfully');
} catch (error) {
return UserPresenter.presentError(error as Error);
}
});
}
}
17 changes: 17 additions & 0 deletions apps/api/src/modules/identity/interface/mappers/user.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { UserDto } from '@dropit/schemas';
import { User } from '../../domain/auth/user.entity';

export const UserMapper = {
toDto(user: User): UserDto {
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
isSuperAdmin: user.isSuperAdmin,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { UserDto } from '@dropit/schemas';
import { UserException } from '../../application/exceptions/user.exceptions';

export const UserPresenter = {
presentOne(user: UserDto) {
return {
status: 200 as const,
body: user,
};
},

presentSuccess(message: string) {
return {
status: 200 as const,
body: { message },
};
},

presentError(error: Error) {
// Handle custom user exceptions
if (error instanceof UserException) {
return {
status: error.statusCode as 400 | 401 | 403 | 404 | 500,
body: { message: error.message }
};
}

// Fallback for unexpected errors
console.error('User unexpected error:', error);
return {
status: 500 as const,
body: { message: 'An error occurred while processing the request' }
};
}
};
Loading