From 3f92a3a2ddde93f76743896c3e51ce5675135c9b Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 24 Jan 2026 16:55:08 +0100 Subject: [PATCH 1/8] feat: add User entity with necessary fields and decorators --- drips/user/user.entity.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 drips/user/user.entity.ts diff --git a/drips/user/user.entity.ts b/drips/user/user.entity.ts new file mode 100644 index 0000000..9790929 --- /dev/null +++ b/drips/user/user.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; +import { Exclude } from 'class-transformer'; + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true, length: 255 }) + email: string; + + @Column({ name: 'password_hash', length: 255 }) + @Exclude() + passwordHash: string; + + @Column({ name: 'first_name', length: 100 }) + firstName: string; + + @Column({ name: 'last_name', length: 100 }) + lastName: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', nullable: true }) + deletedAt?: Date; +} \ No newline at end of file From e9705c495f7817e9e5b4e0d09ee45012db4abb7d Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 24 Jan 2026 16:55:52 +0100 Subject: [PATCH 2/8] feat: create CreateUserDto with validation and API properties --- drips/user/create-user.dto.ts | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 drips/user/create-user.dto.ts diff --git a/drips/user/create-user.dto.ts b/drips/user/create-user.dto.ts new file mode 100644 index 0000000..6916938 --- /dev/null +++ b/drips/user/create-user.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsEmail, + IsNotEmpty, + IsString, + MinLength, + MaxLength, +} from 'class-validator'; + +export class CreateUserDto { + @ApiProperty({ + description: 'User email address', + example: 'john.doe@example.com', + uniqueItems: true, + }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsNotEmpty({ message: 'Email is required' }) + email: string; + + @ApiProperty({ + description: 'User password', + example: 'SecureP@ssw0rd', + minLength: 8, + }) + @IsString() + @IsNotEmpty({ message: 'Password is required' }) + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + password: string; + + @ApiProperty({ + description: 'User first name', + example: 'John', + minLength: 1, + maxLength: 100, + }) + @IsString() + @IsNotEmpty({ message: 'First name is required' }) + @MinLength(1, { message: 'First name cannot be empty' }) + @MaxLength(100, { message: 'First name cannot exceed 100 characters' }) + firstName: string; + + @ApiProperty({ + description: 'User last name', + example: 'Doe', + minLength: 1, + maxLength: 100, + }) + @IsString() + @IsNotEmpty({ message: 'Last name is required' }) + @MinLength(1, { message: 'Last name cannot be empty' }) + @MaxLength(100, { message: 'Last name cannot exceed 100 characters' }) + lastName: string; +} \ No newline at end of file From d8619c7ff78550c259cb6188647e805bc9678a3f Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 24 Jan 2026 16:57:15 +0100 Subject: [PATCH 3/8] feat: add PaginationQueryDto for handling pagination parameters --- drips/user/pagination-query.dto.ts | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 drips/user/pagination-query.dto.ts diff --git a/drips/user/pagination-query.dto.ts b/drips/user/pagination-query.dto.ts new file mode 100644 index 0000000..c8d0275 --- /dev/null +++ b/drips/user/pagination-query.dto.ts @@ -0,0 +1,31 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsInt, Min, Max } from 'class-validator'; + +export class PaginationQueryDto { + @ApiPropertyOptional({ + description: 'Page number (starts from 1)', + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Number of items per page', + example: 10, + minimum: 1, + maximum: 100, + default: 10, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; +} \ No newline at end of file From 2aff785b2afde1aa7dec8ee5cae9e6167985733e Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 24 Jan 2026 16:58:01 +0100 Subject: [PATCH 4/8] feat: create UserResponseDto for user data representation --- drips/user/user-response.dto.ts | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 drips/user/user-response.dto.ts diff --git a/drips/user/user-response.dto.ts b/drips/user/user-response.dto.ts new file mode 100644 index 0000000..ee01644 --- /dev/null +++ b/drips/user/user-response.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude, Expose } from 'class-transformer'; + +@Exclude() +export class UserResponseDto { + @ApiProperty({ + description: 'User unique identifier', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @Expose() + id: string; + + @ApiProperty({ + description: 'User email address', + example: 'john.doe@example.com', + }) + @Expose() + email: string; + + @ApiProperty({ + description: 'User first name', + example: 'John', + }) + @Expose() + firstName: string; + + @ApiProperty({ + description: 'User last name', + example: 'Doe', + }) + @Expose() + lastName: string; + + @ApiProperty({ + description: 'User creation timestamp', + example: '2024-01-24T10:30:00.000Z', + }) + @Expose() + createdAt: Date; + + @ApiProperty({ + description: 'User last update timestamp', + example: '2024-01-24T10:30:00.000Z', + }) + @Expose() + updatedAt: Date; +} \ No newline at end of file From 43e40eb447fa1c16c8be3831dbf6782ca00b2b5b Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 24 Jan 2026 16:59:15 +0100 Subject: [PATCH 5/8] feat: add PaginatedUsersResponseDto for handling paginated user responses --- drips/user/paginated-users-response.dto.ts | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 drips/user/paginated-users-response.dto.ts diff --git a/drips/user/paginated-users-response.dto.ts b/drips/user/paginated-users-response.dto.ts new file mode 100644 index 0000000..7f87713 --- /dev/null +++ b/drips/user/paginated-users-response.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserResponseDto } from './user-response.dto'; + +export class PaginatedUsersResponseDto { + @ApiProperty({ + description: 'Array of users', + type: [UserResponseDto], + }) + data: UserResponseDto[]; + + @ApiProperty({ + description: 'Pagination metadata', + example: { + total: 100, + page: 1, + limit: 10, + totalPages: 10, + }, + }) + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} \ No newline at end of file From 8171a29ceb28e8ff01d39c979f7bcc8d4bedd9cb Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 24 Jan 2026 16:59:53 +0100 Subject: [PATCH 6/8] feat: implement UsersService with user creation, retrieval, update, and soft delete functionalities --- drips/user/users.service.ts | 218 ++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 drips/user/users.service.ts diff --git a/drips/user/users.service.ts b/drips/user/users.service.ts new file mode 100644 index 0000000..3a6eabd --- /dev/null +++ b/drips/user/users.service.ts @@ -0,0 +1,218 @@ +import { + Injectable, + NotFoundException, + ConflictException, + InternalServerErrorException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcrypt'; +import { User } from './entities/user.entity'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; +import { PaginatedUsersResponseDto } from './dto/paginated-users-response.dto'; +import { UserResponseDto } from './dto/user-response.dto'; +import { plainToInstance } from 'class-transformer'; + +@Injectable() +export class UsersService { + private readonly SALT_ROUNDS = 10; + + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + ) {} + + /** + * Create a new user + */ + async create(createUserDto: CreateUserDto): Promise { + try { + // Check if user with email already exists + const existingUser = await this.usersRepository.findOne({ + where: { email: createUserDto.email }, + withDeleted: true, + }); + + if (existingUser) { + throw new ConflictException( + `User with email ${createUserDto.email} already exists`, + ); + } + + // Hash the password + const passwordHash = await bcrypt.hash( + createUserDto.password, + this.SALT_ROUNDS, + ); + + // Create user entity + const user = this.usersRepository.create({ + email: createUserDto.email, + passwordHash, + firstName: createUserDto.firstName, + lastName: createUserDto.lastName, + }); + + // Save to database + const savedUser = await this.usersRepository.save(user); + + // Return response without password + return this.toResponseDto(savedUser); + } catch (error) { + if (error instanceof ConflictException) { + throw error; + } + // Handle unique constraint violation from database + if (error.code === '23505' || error.code === 'ER_DUP_ENTRY') { + throw new ConflictException( + `User with email ${createUserDto.email} already exists`, + ); + } + throw new InternalServerErrorException( + 'An error occurred while creating the user', + ); + } + } + + /** + * Find all users with pagination + */ + async findAll( + paginationQuery: PaginationQueryDto, + ): Promise { + const { page = 1, limit = 10 } = paginationQuery; + const skip = (page - 1) * limit; + + const [users, total] = await this.usersRepository.findAndCount({ + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + const data = users.map((user) => this.toResponseDto(user)); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Find one user by ID + */ + async findOne(id: string): Promise { + const user = await this.usersRepository.findOne({ + where: { id }, + }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return this.toResponseDto(user); + } + + /** + * Find user by email (internal use, includes password hash) + */ + async findByEmail(email: string): Promise { + return this.usersRepository.findOne({ + where: { email }, + }); + } + + /** + * Update a user + */ + async update( + id: string, + updateUserDto: UpdateUserDto, + ): Promise { + // Check if user exists + const user = await this.usersRepository.findOne({ + where: { id }, + }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + try { + // Check for email uniqueness if email is being updated + if (updateUserDto.email && updateUserDto.email !== user.email) { + const existingUser = await this.usersRepository.findOne({ + where: { email: updateUserDto.email }, + withDeleted: true, + }); + + if (existingUser) { + throw new ConflictException( + `User with email ${updateUserDto.email} already exists`, + ); + } + } + + // Update fields + if (updateUserDto.email) user.email = updateUserDto.email; + if (updateUserDto.firstName) user.firstName = updateUserDto.firstName; + if (updateUserDto.lastName) user.lastName = updateUserDto.lastName; + + // Update password if provided + if (updateUserDto.password) { + user.passwordHash = await bcrypt.hash( + updateUserDto.password, + this.SALT_ROUNDS, + ); + } + + // Save updated user + const updatedUser = await this.usersRepository.save(user); + + return this.toResponseDto(updatedUser); + } catch (error) { + if (error instanceof ConflictException) { + throw error; + } + // Handle unique constraint violation from database + if (error.code === '23505' || error.code === 'ER_DUP_ENTRY') { + throw new ConflictException( + `User with email ${updateUserDto.email} already exists`, + ); + } + throw new InternalServerErrorException( + 'An error occurred while updating the user', + ); + } + } + + /** + * Soft delete a user + */ + async remove(id: string): Promise { + const user = await this.usersRepository.findOne({ + where: { id }, + }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + await this.usersRepository.softDelete(id); + } + + /** + * Convert User entity to UserResponseDto (excludes password) + */ + private toResponseDto(user: User): UserResponseDto { + return plainToInstance(UserResponseDto, user, { + excludeExtraneousValues: true, + }); + } +} \ No newline at end of file From 4b75a6a8f58754536577e37b4e5fd677b0f66fdb Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 24 Jan 2026 17:00:33 +0100 Subject: [PATCH 7/8] feat: implement UsersController with CRUD operations for user management --- drips/user/users.controller.ts | 174 +++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 drips/user/users.controller.ts diff --git a/drips/user/users.controller.ts b/drips/user/users.controller.ts new file mode 100644 index 0000000..70c336b --- /dev/null +++ b/drips/user/users.controller.ts @@ -0,0 +1,174 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + HttpCode, + HttpStatus, + UseInterceptors, + ClassSerializerInterceptor, + ParseUUIDPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiQuery, + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiConflictResponse, +} from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; +import { UserResponseDto } from './dto/user-response.dto'; +import { PaginatedUsersResponseDto } from './dto/paginated-users-response.dto'; + +@ApiTags('users') +@Controller('users') +@UseInterceptors(ClassSerializerInterceptor) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + @ApiOperation({ + summary: 'Create a new user', + description: 'Creates a new user with the provided information', + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'User successfully created', + type: UserResponseDto, + }) + @ApiBadRequestResponse({ + description: 'Invalid input data', + }) + @ApiConflictResponse({ + description: 'User with this email already exists', + }) + create(@Body() createUserDto: CreateUserDto): Promise { + return this.usersService.create(createUserDto); + } + + @Get() + @ApiOperation({ + summary: 'Get all users', + description: 'Retrieves a paginated list of all users', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page number (default: 1)', + example: 1, + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Items per page (default: 10, max: 100)', + example: 10, + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'List of users retrieved successfully', + type: PaginatedUsersResponseDto, + }) + @ApiBadRequestResponse({ + description: 'Invalid pagination parameters', + }) + findAll( + @Query() paginationQuery: PaginationQueryDto, + ): Promise { + return this.usersService.findAll(paginationQuery); + } + + @Get(':id') + @ApiOperation({ + summary: 'Get user by ID', + description: 'Retrieves a single user by their unique identifier', + }) + @ApiParam({ + name: 'id', + description: 'User UUID', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'User found', + type: UserResponseDto, + }) + @ApiNotFoundResponse({ + description: 'User not found', + }) + @ApiBadRequestResponse({ + description: 'Invalid UUID format', + }) + findOne( + @Param('id', new ParseUUIDPipe()) id: string, + ): Promise { + return this.usersService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ + summary: 'Update user', + description: 'Updates an existing user with the provided information', + }) + @ApiParam({ + name: 'id', + description: 'User UUID', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'User successfully updated', + type: UserResponseDto, + }) + @ApiNotFoundResponse({ + description: 'User not found', + }) + @ApiBadRequestResponse({ + description: 'Invalid input data or UUID format', + }) + @ApiConflictResponse({ + description: 'Email already exists', + }) + update( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() updateUserDto: UpdateUserDto, + ): Promise { + return this.usersService.update(id, updateUserDto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete user', + description: 'Soft deletes a user (marks as deleted but keeps in database)', + }) + @ApiParam({ + name: 'id', + description: 'User UUID', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'User successfully deleted', + }) + @ApiNotFoundResponse({ + description: 'User not found', + }) + @ApiBadRequestResponse({ + description: 'Invalid UUID format', + }) + remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { + return this.usersService.remove(id); + } +} \ No newline at end of file From 318eadbb08388a2969de2f60cf8c3e1a51cdd92a Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Sat, 24 Jan 2026 17:01:37 +0100 Subject: [PATCH 8/8] feat: create UsersModule to encapsulate user-related services and controllers --- drips/user/users.module.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 drips/user/users.module.ts diff --git a/drips/user/users.module.ts b/drips/user/users.module.ts new file mode 100644 index 0000000..d8281b8 --- /dev/null +++ b/drips/user/users.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UsersService } from './users.service'; +import { UsersController } from './users.controller'; +import { User } from './entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} \ No newline at end of file